feat(go): Port full Ananta private server to Go architecture

This commit is contained in:
CAtlas0784 committed 2026-10-29 03:12:00 +07:00
1 parent ca2e80f9b7
commit c73d744649
28 files changed
+3585 -57

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type Config struct {
Client ClientConfig `json:"client"`
Network NetworkConfig `json:"network"`
Admin AdminConfig `json:"admin"`
Player PlayerConfig `json:"player"`
World WorldConfig `json:"world"`
}
type ClientConfig struct {
Version int `json:"version"`
ArtifactVersion string `json:"artifactVersion"`
ServerID int `json:"serverId"`
Aid int `json:"aid"`
RpcMd5 string `json:"rpcMd5"`
NoMoreHotfixPatchError uint32 `json:"noMoreHotfixPatchError"`
}
type NetworkConfig struct {
BindHost string `json:"bindHost"`
AdvertisedHost string `json:"advertisedHost"`
LoginPorts []int `json:"loginPorts"`
GamePort int `json:"gamePort"`
SocketHeartbeatMs int `json:"socketHeartbeatMs"`
WriteTimeoutMs int `json:"writeTimeoutMs"`
Proxy ProxyConfig `json:"proxy"`
}
type ProxyConfig struct {
HttpsPort int `json:"httpsPort"`
HttpPort int `json:"httpPort"`
LoginListPort int `json:"loginListPort"`
LoginTcpPort int `json:"loginTcpPort"`
GameTcpPort int `json:"gameTcpPort"`
SceneSubPort int `json:"sceneSubPort"`
FastPatchPort int `json:"fastPatchPort"`
UpdateHost string `json:"updateHost"`
CertificatePassphrase string `json:"certificatePassphrase"`
}
type AdminConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
}
type PlayerConfig struct {
Pid uint64 `json:"pid"`
InitialUnitId uint64 `json:"initialUnitId"`
InitialSpiritTemplateId uint32 `json:"initialSpiritTemplateId"`
AccountId string `json:"accountId"`
UserName string `json:"userName"`
DisplayName string `json:"displayName"`
LoginToken string `json:"loginToken"`
GameToken string `json:"gameToken"`
LoginUniverseId uint32 `json:"loginUniverseId"`
ShareToken string `json:"shareToken"`
FpPassToken string `json:"fpPassToken"`
Skey string `json:"skey"`
}
type Vec3 struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
type WorldConfig struct {
RaidId uint32 `json:"raidId"`
SceneInstanceId uint64 `json:"sceneInstanceId"`
UniverseId uint32 `json:"universeId"`
Spawn Vec3 `json:"spawn"`
Facing float32 `json:"facing"`
EntryOpeningEnabled bool `json:"entryOpeningEnabled"`
}
// LoadConfig reads and parses private-server.json from the specified path or standard locations.
func LoadConfig(path string) (*Config, error) {
if path == "" {
candidates := []string{
"config/private-server.json",
"../config/private-server.json",
"../../config/private-server.json",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
path = c
break
}
}
}
if path == "" {
return nil, fmt.Errorf("config/private-server.json not found")
}
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read config from %s: %w", absPath, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse json config from %s: %w", absPath, err)
}
// Apply default values if omitted
if cfg.Network.BindHost == "" {
cfg.Network.BindHost = "0.0.0.0"
}
if cfg.Network.AdvertisedHost == "" {
cfg.Network.AdvertisedHost = "127.0.0.1"
}
if len(cfg.Network.LoginPorts) == 0 {
cfg.Network.LoginPorts = []int{5200, 5201}
}
if cfg.Network.GamePort == 0 {
cfg.Network.GamePort = 5202
}
if cfg.Network.Proxy.HttpsPort == 0 {
cfg.Network.Proxy.HttpsPort = 443
}
if cfg.Network.Proxy.HttpPort == 0 {
cfg.Network.Proxy.HttpPort = 80
}
if cfg.Network.Proxy.LoginListPort == 0 {
cfg.Network.Proxy.LoginListPort = 5801
}
if cfg.Admin.Port == 0 {
cfg.Admin.Port = 17888
}
if cfg.Player.Pid == 0 {
cfg.Player.Pid = 100
}
if cfg.Player.InitialUnitId == 0 {
cfg.Player.InitialUnitId = 100000000000
}
if cfg.Player.InitialSpiritTemplateId == 0 {
cfg.Player.InitialSpiritTemplateId = 15020967 // Default character
}
return &cfg, nil
}
+397
View File
@@ -0,0 +1,397 @@
package gameserver
import (
"context"
"fmt"
"io"
"log"
"net"
"path/filepath"
"sync"
"ananta-ps/internal/config"
"ananta-ps/pkg/crypto/chacha8"
"ananta-ps/pkg/protocol"
)
type Server struct {
cfg *config.Config
templatePath string
ln net.Listener
cancelFunc context.CancelFunc
wg sync.WaitGroup
sessions map[string]*Session
mu sync.RWMutex
}
type Session struct {
id string
conn net.Conn
readCipher *chacha8.UxChaCha8
sendCipher *chacha8.UxChaCha8
sendMu sync.Mutex
pid uint64
unitId uint64
templateId uint32
posX float32
posY float32
posZ float32
facing float32
activeScene uint64
activeRaid uint32
universeId uint32
}
func New(cfg *config.Config, templatePath string) *Server {
if templatePath == "" {
candidates := []string{
"data/player_info_template.bin",
"../data/player_info_template.bin",
}
for _, c := range candidates {
abs, _ := filepath.Abs(c)
templatePath = abs
break
}
}
return &Server{
cfg: cfg,
templatePath: templatePath,
sessions: make(map[string]*Session),
}
}
func (s *Server) Start() error {
ctx, cancel := context.WithCancel(context.Background())
s.cancelFunc = cancel
addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, s.cfg.Network.GamePort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on game server %s: %w", addr, err)
}
s.ln = ln
log.Printf("[GAME-SERVER] Listening on %s:%d", s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort)
s.wg.Add(1)
go s.acceptLoop(ctx)
return nil
}
func (s *Server) Stop() error {
if s.cancelFunc != nil {
s.cancelFunc()
}
if s.ln != nil {
_ = s.ln.Close()
}
s.wg.Wait()
return nil
}
func (s *Server) GetActiveSessions() []*Session {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*Session, 0, len(s.sessions))
for _, sess := range s.sessions {
list = append(list, sess)
}
return list
}
func (s *Server) acceptLoop(ctx context.Context) {
defer s.wg.Done()
for {
conn, err := s.ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
log.Printf("[GAME-SERVER] Accept error: %v", err)
return
}
}
go s.handleConnection(ctx, conn)
}
}
func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
sess.sendMu.Lock()
defer sess.sendMu.Unlock()
var payload []byte
switch packet.Kind {
case protocol.PacketKindInvoke:
payload = protocol.BuildInvoke(packet.MethodID, packet.InvokeID, packet.Body)
case protocol.PacketKindReturn:
payload = protocol.BuildReturn(packet.MethodID, packet.InvokeID, packet.Error, packet.Body)
case protocol.PacketKindNotify:
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
}
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
}
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
MethodID: methodID,
Error: 0,
Body: nil,
})
}
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
MethodID: methodID,
Error: 0,
Body: body,
})
}
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindNotify,
MethodID: methodID,
Body: body,
})
}
func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
log.Printf("[GAME-SERVER] Client connected from %s", conn.RemoteAddr())
readCipher, writeCipher, err := protocol.HandleHandshake(conn)
if err != nil {
log.Printf("[GAME-SERVER] Handshake error from %s: %v", conn.RemoteAddr(), err)
return
}
sess := &Session{
id: conn.RemoteAddr().String(),
conn: conn,
readCipher: readCipher,
sendCipher: writeCipher,
pid: s.cfg.Player.Pid,
unitId: s.cfg.Player.InitialUnitId,
templateId: s.cfg.Player.InitialSpiritTemplateId,
posX: float32(s.cfg.World.Spawn.X),
posY: float32(s.cfg.World.Spawn.Y),
posZ: float32(s.cfg.World.Spawn.Z),
facing: s.cfg.World.Facing,
activeRaid: s.cfg.World.RaidId,
activeScene: s.cfg.World.SceneInstanceId,
universeId: s.cfg.World.UniverseId,
}
s.mu.Lock()
s.sessions[sess.id] = sess
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.sessions, sess.id)
s.mu.Unlock()
}()
for {
select {
case <-ctx.Done():
return
default:
}
frame, err := protocol.ReadFrame(conn, sess.readCipher)
if err != nil {
if err != io.EOF {
log.Printf("[GAME-SERVER] Connection %s read error: %v", conn.RemoteAddr(), err)
}
return
}
// Frame mode 0x03 is heartbeat
if frame.Mode == 0x03 {
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
continue
}
packet := protocol.ParseRpcPacket(frame.Payload)
if err := s.dispatch(sess, &packet); err != nil {
log.Printf("[GAME-SERVER] Dispatch error for method %d: %v", packet.MethodID, err)
}
}
}
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
switch pkt.MethodID {
case protocol.MethodLoginGame:
log.Printf("[WORLD] LoginGame received, preparing world state for player %d", sess.pid)
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
// 1. SendServerTimeGame
_ = sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0))
// 2. SyncPlayerInfo (from template binary)
playerInfoBytes, err := protocol.EncodePlayerInfo(s.templatePath, s.cfg.Client.Aid, sess.pid, s.cfg.Player.AccountId, s.cfg.Player.DisplayName)
if err == nil {
_ = sess.NotifyData(protocol.MethodSyncPlayerInfo, playerInfoBytes)
} else {
log.Printf("[WORLD] Warning: player_info_template.bin load error: %v", err)
}
// 3. SyncEnterScene
enterScenePayload := protocol.EncodeEnterScene(
sess.pid,
sess.activeRaid,
sess.activeScene,
sess.universeId,
sess.unitId,
sess.templateId,
sess.posX,
sess.posY,
sess.posZ,
sess.facing,
)
log.Printf("[WORLD-V7] Sending SyncEnterScene raid=%d instance=%d universe=%d unit=%d template=%d pos=(%.2f,%.2f,%.2f)",
sess.activeRaid, sess.activeScene, sess.universeId, sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ)
return sess.NotifyData(protocol.MethodSyncEnterScene, enterScenePayload)
case protocol.MethodAskLoadSceneCompleted:
log.Printf("[WORLD-V7] AskLoadSceneCompleted ack received, committing V7 4-step handoff")
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
// V7 4-step Handoff:
// Step 1: SyncLogicAgentEnter
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId))
// Step 2: SyncManagedLogicAgent
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0))
// Step 3: SyncRaidBattleUnitSpirit
unitProjection := protocol.EncodeRaidBattleUnitSpirit(sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection)
// Step 4: SyncPlayerCurrentSpirit
return sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, sess.templateId, sess.unitId, false))
case protocol.MethodAskLoadingFinished:
log.Printf("[WORLD-V7] AskLoadingFinished received, scene ready")
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
// 1. SyncSceneLoadCompleted
_ = sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene))
// 2. SyncGamePause (false)
wPause := protocol.NewUxWriter()
wPause.Bool(false)
_ = sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes())
// 3. Unlock Map Fog
wFog := protocol.NewUxWriter()
wFog.U32(uint32(sess.activeScene)).Bool(true)
_ = sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes())
// 4. Combat power
wPower := protocol.NewUxWriter()
wPower.U8(0xFF).Int7(1).U32(sess.templateId).F32(1000.0)
_ = sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes())
// 5. Unlock Diving
wDive := protocol.NewUxWriter()
wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true")
return sess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes())
case protocol.MethodAskLoadedInSameScene:
w := protocol.NewUxWriter()
w.U64(sess.pid).F64(1.0)
return sess.NotifyData(protocol.MethodSyncPlayerLoadRate, w.Bytes())
case protocol.MethodAskMoveStart, protocol.MethodAskMoveStop:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskUpdatePlayerPosition:
r := protocol.NewUxReader(pkt.Body)
if x, err := r.F32(); err == nil {
sess.posX = x
}
if y, err := r.F32(); err == nil {
sess.posY = y
}
if z, err := r.F32(); err == nil {
sess.posZ = z
}
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskPreSwitchSpirit:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskSwitchSpirit:
// Clean DRMK character swap
r := protocol.NewUxReader(pkt.Body)
newTemplateId := sess.templateId
if tid, err := r.U32(); err == nil {
newTemplateId = tid
}
newUnitId := sess.unitId + 1
log.Printf("[SWITCH] Switching character %d -> %d", sess.templateId, newTemplateId)
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
_ = sess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(sess.unitId))
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId))
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, sess.pid, 0))
newProj := protocol.EncodeRaidBattleUnitSpirit(newUnitId, newTemplateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj)
_ = sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true))
wInSitu := protocol.NewUxWriter()
wInSitu.U32(newTemplateId).U64(newUnitId)
_ = sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes())
sess.templateId = newTemplateId
sess.unitId = newUnitId
return nil
case protocol.MethodAskSummonVehicle, protocol.MethodAskDriveVehicle, protocol.MethodAskExitVehicle:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskPlayerAttack, protocol.MethodAskPlayerCastSkill, protocol.MethodAskUnlockFightStyle:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
default:
if pkt.Kind == protocol.PacketKindInvoke {
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
}
}
return nil
}
// Teleport moves an active session to the specified coordinates.
func (sess *Session) Teleport(x, y, z, facing float32) error {
sess.posX = x
sess.posY = y
sess.posZ = z
sess.facing = facing
w := protocol.NewUxWriter()
w.U64(sess.unitId).F32(x).F32(y).F32(z).F32(facing).U8(0).Bool(false).U8(6) // SetPositionType.Teleport = 6
return sess.NotifyData(protocol.MethodSyncUnitPositionAndFacing, w.Bytes())
}
+129
View File
@@ -0,0 +1,129 @@
package gameserver
import (
"crypto/aes"
"crypto/cipher"
"net"
"testing"
"time"
"ananta-ps/internal/config"
"ananta-ps/pkg/crypto/chacha8"
"ananta-ps/pkg/protocol"
)
func TestGameServerWorldEntry(t *testing.T) {
cfg := &config.Config{
Client: config.ClientConfig{
ServerID: 801,
Aid: 5944,
},
Network: config.NetworkConfig{
BindHost: "127.0.0.1",
AdvertisedHost: "127.0.0.1",
GamePort: 15203,
},
Player: config.PlayerConfig{
Pid: 100,
InitialUnitId: 100000000000,
InitialSpiritTemplateId: 15020967,
AccountId: "test_acc",
DisplayName: "test_player",
},
World: config.WorldConfig{
RaidId: 23300888,
SceneInstanceId: 20001222,
UniverseId: 76000888,
Spawn: config.Vec3{X: 100, Y: 0, Z: 200},
Facing: 0,
},
}
srv := New(cfg, "../../data/player_info_template.bin")
if err := srv.Start(); err != nil {
t.Fatalf("failed to start game server: %v", err)
}
defer srv.Stop()
// Connect client
conn, err := net.DialTimeout("tcp", "127.0.0.1:15203", 2*time.Second)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
// 1. Client handshake
clientHandshake := make([]byte, 260)
aesKey := []byte("0123456789abcdef")
copy(clientHandshake[4:20], aesKey)
if err := protocol.WriteFrame(conn, 0x01, clientHandshake, nil); err != nil {
t.Fatalf("failed to write handshake: %v", err)
}
// 2. Server reply
replyFrame, err := protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatalf("failed to read reply: %v", err)
}
aesNonce := replyFrame.Payload[8:20]
encryptedHead := replyFrame.Payload[20:96]
tag := replyFrame.Payload[96:112]
cipherBlock, err := aes.NewCipher(aesKey)
if err != nil {
t.Fatalf("failed to create aes cipher: %v", err)
}
aesGcm, err := cipher.NewGCM(cipherBlock)
if err != nil {
t.Fatalf("failed to create aes gcm: %v", err)
}
fullCipher := append(encryptedHead, tag...)
chaChaHead, err := aesGcm.Open(nil, aesNonce, fullCipher, nil)
if err != nil {
t.Fatalf("failed to decrypt chacha head: %v", err)
}
clientSendCipher, _ := chacha8.New(chaChaHead[0:32], chaChaHead[64:76])
clientReadCipher, _ := chacha8.New(chaChaHead[32:64], chaChaHead[64:76])
// 3. Send LoginGame Invoke
invokePayload := protocol.BuildInvoke(protocol.MethodLoginGame, 1, nil)
if err := protocol.WriteFrame(conn, 0x09, invokePayload, clientSendCipher); err != nil {
t.Fatalf("failed to write LoginGame: %v", err)
}
// 4. Read Return frame
retFrame, err := protocol.ReadFrame(conn, clientReadCipher)
if err != nil {
t.Fatalf("failed to read return: %v", err)
}
pkt := protocol.ParseRpcPacket(retFrame.Payload)
if pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 1 {
t.Fatalf("expected return for invoke 1, got %v", pkt)
}
// 5. Read S2C notifications (ServerTime, SyncPlayerInfo, SyncEnterScene)
foundEnterScene := false
for i := 0; i < 3; i++ {
notifyFrame, err := protocol.ReadFrame(conn, clientReadCipher)
if err != nil {
t.Fatalf("failed to read notify frame: %v", err)
}
nPkt := protocol.ParseRpcPacket(notifyFrame.Payload)
if nPkt.MethodID == protocol.MethodSyncEnterScene {
foundEnterScene = true
}
}
if !foundEnterScene {
t.Fatalf("expected SyncEnterScene in notifications")
}
// Verify session tracking
sessions := srv.GetActiveSessions()
if len(sessions) != 1 {
t.Fatalf("expected 1 active session, got %d", len(sessions))
}
}
+433
View File
@@ -0,0 +1,433 @@
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{
"Ananta.Proxy/proxy/public",
"proxy/public",
"../Ananta.Proxy/proxy/public",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
publicDir = c
break
}
}
}
if certDir == "" {
candidates := []string{
"Ananta.Proxy/proxy/certs",
"proxy/certs",
"../Ananta.Proxy/proxy/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 {
ctx, cancel := context.WithCancel(context.Background())
g.cancelFunc = cancel
mux := http.NewServeMux()
mux.HandleFunc("/", g.handleDispatch)
// 1. Port 5801: LoginList & Mobile Dispatch Server
listAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.LoginListPort)
g.listSrv = &http.Server{
Addr: listAddr,
Handler: mux,
}
go func() {
log.Printf("[GATEWAY] LoginList & Mobile Dispatch listening on http://%s (Port %d)", g.cfg.Network.AdvertisedHost, g.cfg.Network.Proxy.LoginListPort)
if err := g.listSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] LoginList server error: %v", err)
}
}()
// 2. Port 80: HTTP Gateway
httpAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpPort)
g.httpSrv = &http.Server{
Addr: httpAddr,
Handler: mux,
}
go func() {
log.Printf("[GATEWAY] HTTP Proxy listening on port %d", g.cfg.Network.Proxy.HttpPort)
if err := g.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] HTTP Proxy error (might need Admin privileges for port 80): %v", err)
}
}()
// 3. Port 443: HTTPS Proxy with TLS SNI
certFile := filepath.Join(g.certDir, "cert.pem")
keyFile := filepath.Join(g.certDir, "key.pem")
if _, err := os.Stat(certFile); err == nil {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
return &cert, nil
},
}
httpsAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpsPort)
g.httpsSrv = &http.Server{
Addr: httpsAddr,
Handler: mux,
TLSConfig: tlsConfig,
}
go func() {
log.Printf("[GATEWAY] HTTPS SNI Proxy listening on port %d", g.cfg.Network.Proxy.HttpsPort)
ln, err := net.Listen("tcp", httpsAddr)
if err != nil {
log.Printf("[GATEWAY] HTTPS Proxy listen error (port 443): %v", err)
return
}
tlsLn := tls.NewListener(ln, tlsConfig)
if err := g.httpsSrv.Serve(tlsLn); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] HTTPS Proxy error: %v", err)
}
}()
} else {
log.Printf("[GATEWAY] Warning: failed to load TLS certificate %s: %v", certFile, err)
}
} else {
log.Printf("[GATEWAY] Warning: cert.pem not found in %s; HTTPS port 443 disabled", g.certDir)
}
_ = ctx
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)
}
+51
View File
@@ -0,0 +1,51 @@
package gateway
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"ananta-ps/internal/config"
)
func TestGatewayDispatch(t *testing.T) {
cfg := &config.Config{
Client: config.ClientConfig{
ServerID: 801,
},
Network: config.NetworkConfig{
AdvertisedHost: "127.0.0.1",
LoginPorts: []int{5200, 5201},
Proxy: config.ProxyConfig{
LoginListPort: 5801,
},
},
}
gw := New(cfg, "../../Ananta.Proxy/proxy/public", "../../Ananta.Proxy/proxy/certs")
// Test LoginList
req := httptest.NewRequest("GET", "/LoginList", nil)
w := httptest.NewRecorder()
gw.handleDispatch(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body := w.Body.String()
if !strings.Contains(body, "127.0.0.1:5200") {
t.Fatalf("expected 127.0.0.1:5200 in LoginList, got %s", body)
}
// Test ServerList
req = httptest.NewRequest("GET", "/OuterTest1/serverlist.txt", nil)
w = httptest.NewRecorder()
gw.handleDispatch(w, req)
if w.Result().StatusCode != http.StatusOK {
t.Fatalf("expected 200 for serverlist.txt, got %d", w.Result().StatusCode)
}
if !strings.Contains(w.Body.String(), "QA101,Outer,http://127.0.0.1:5801/LoginList,1,14") {
t.Fatalf("unexpected serverlist body: %s", w.Body.String())
}
}
+303
View File
@@ -0,0 +1,303 @@
package logingate
import (
"context"
"fmt"
"io"
"log"
"net"
"sync"
"ananta-ps/internal/config"
"ananta-ps/pkg/crypto/chacha8"
"ananta-ps/pkg/protocol"
)
type Server struct {
cfg *config.Config
listeners []net.Listener
cancelFunc context.CancelFunc
wg sync.WaitGroup
}
func New(cfg *config.Config) *Server {
return &Server{
cfg: cfg,
}
}
func (s *Server) Start() error {
ctx, cancel := context.WithCancel(context.Background())
s.cancelFunc = cancel
for _, port := range s.cfg.Network.LoginPorts {
addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, port)
ln, err := net.Listen("tcp", addr)
if err != nil {
s.Stop()
return fmt.Errorf("failed to listen on login gate %s: %w", addr, err)
}
s.listeners = append(s.listeners, ln)
log.Printf("[LOGIN-GATE] Listening on %s:%d", s.cfg.Network.AdvertisedHost, port)
s.wg.Add(1)
go s.acceptLoop(ctx, ln, port)
}
return nil
}
func (s *Server) Stop() error {
if s.cancelFunc != nil {
s.cancelFunc()
}
for _, ln := range s.listeners {
_ = ln.Close()
}
s.wg.Wait()
return nil
}
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, port int) {
defer s.wg.Done()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
log.Printf("[LOGIN-GATE] Accept error on port %d: %v", port, err)
return
}
}
go s.handleConnection(ctx, conn, port)
}
}
type Session struct {
conn net.Conn
readCipher *chacha8.UxChaCha8
sendCipher *chacha8.UxChaCha8
mu sync.Mutex
}
func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
sess.mu.Lock()
defer sess.mu.Unlock()
var payload []byte
switch packet.Kind {
case protocol.PacketKindInvoke:
payload = protocol.BuildInvoke(packet.MethodID, packet.InvokeID, packet.Body)
case protocol.PacketKindReturn:
payload = protocol.BuildReturn(packet.MethodID, packet.InvokeID, packet.Error, packet.Body)
case protocol.PacketKindNotify:
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
}
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
}
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
MethodID: methodID,
Error: 0,
Body: nil,
})
}
func (sess *Session) ReturnEmpty(invokeID int32, methodID uint32, errCode int32) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
MethodID: methodID,
Error: errCode,
Body: nil,
})
}
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
MethodID: methodID,
Error: 0,
Body: body,
})
}
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindNotify,
MethodID: methodID,
Body: body,
})
}
func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int) {
defer conn.Close()
log.Printf("[LOGIN-GATE] Client connected from %s on port %d", conn.RemoteAddr(), port)
readCipher, writeCipher, err := protocol.HandleHandshake(conn)
if err != nil {
log.Printf("[LOGIN-GATE] Handshake error from %s: %v", conn.RemoteAddr(), err)
return
}
sess := &Session{
conn: conn,
readCipher: readCipher,
sendCipher: writeCipher,
}
for {
select {
case <-ctx.Done():
return
default:
}
frame, err := protocol.ReadFrame(conn, sess.readCipher)
if err != nil {
if err != io.EOF {
log.Printf("[LOGIN-GATE] Connection %s read error: %v", conn.RemoteAddr(), err)
}
return
}
// Frame mode 0x03 is heartbeat
if frame.Mode == 0x03 {
// Echo heartbeat back
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
continue
}
packet := protocol.ParseRpcPacket(frame.Payload)
if err := s.dispatch(sess, &packet); err != nil {
log.Printf("[LOGIN-GATE] Dispatch error for method %d: %v", packet.MethodID, err)
}
}
}
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
switch pkt.MethodID {
case protocol.MethodCheckVersion, protocol.MethodHasOnlinePlayer:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskNewHotFixPatchLogin:
return sess.ReturnEmpty(pkt.InvokeID, pkt.MethodID, int32(s.cfg.Client.NoMoreHotfixPatchError))
case protocol.MethodRequestPatchesCheckDataFromLogin:
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, protocol.EncodePatchCheck())
case protocol.MethodRequestPatchesFromLogin:
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, protocol.EncodeEmptyList())
case protocol.MethodAskUniSdkShareToken_Login, protocol.MethodAskUniSdkShareToken_Gate:
w := protocol.NewUxWriter()
w.UxString(s.cfg.Player.ShareToken)
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes())
case protocol.MethodRequestFpPassToken:
w := protocol.NewUxWriter()
w.UxString(s.cfg.Player.FpPassToken)
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes())
case protocol.MethodCheckAccount, protocol.MethodCheckAccountPassBy, protocol.MethodCheckAccountOpenId:
body := protocol.EncodeCheckAccount(
s.cfg.Player.AccountId,
s.cfg.Player.UserName,
s.cfg.Player.LoginToken,
s.cfg.Client.Aid,
s.cfg.Client.ServerID,
s.cfg.Player.Pid,
)
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body)
case protocol.MethodTryLogin:
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
// Send SyncRoleList (35167428)
w := protocol.NewUxWriter()
w.U64(s.cfg.Player.Pid)
return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes())
case protocol.MethodRequestCreateRoleEx:
w := protocol.NewUxWriter()
w.U64(s.cfg.Player.Pid)
if err := sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()); err != nil {
return err
}
return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes())
case protocol.MethodRequestEnterGame, protocol.MethodDebugRequestEnterGame:
body := protocol.EncodeEnterGame(
s.cfg.Client.Aid,
s.cfg.Player.Pid,
s.cfg.Network.AdvertisedHost,
s.cfg.Network.LoginPorts[0],
s.cfg.Player.LoginToken,
s.cfg.Client.ServerID,
s.cfg.Player.AccountId,
)
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body)
case protocol.MethodAskCloseConnectionToGate:
w := protocol.NewUxWriter()
w.U32(0)
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes())
case protocol.MethodGetServerTime:
if pkt.Kind == protocol.PacketKindInvoke {
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
}
return sess.NotifyData(protocol.MethodSendServerTime, protocol.EncodeServerTime(0))
case protocol.MethodQuerySkey:
w := protocol.NewUxWriter()
w.UxString(s.cfg.Player.Skey)
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes())
case protocol.MethodGateLogin:
log.Printf("[GATE] Authenticated Gate session registered for IMasterToClient S2C")
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
// 1. SendServerTime
_ = sess.NotifyData(protocol.MethodSendServerTime, protocol.EncodeServerTime(0))
// 2. SyncGameSwitchToClient_3 (53940944) - Crucial for vehicles, NPCs, gadgets
gameSwitches := protocol.BuildGameSwitchPayload()
log.Printf("[GATE] SyncGameSwitchToClient_3 entries=%d body=%dB", len(protocol.GameSwitchCatalog), len(gameSwitches))
_ = sess.NotifyData(protocol.MethodSyncGameSwitchToClient_3, gameSwitches)
// 3. SyncPlayerGameServerInfo (52737039) - Handoff to Game Server
gameInfo := protocol.EncodeGameServerInfo(s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort, s.cfg.Player.GameToken)
return sess.NotifyData(protocol.MethodSyncPlayerGameServerInfo, gameInfo)
case protocol.MethodGetFriendApplicationListToMe,
protocol.MethodGetAllChatGroupLatestMessage,
protocol.MethodGetSimplePlayerInfoByPidList:
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, protocol.EncodeEmptyList())
case protocol.MethodReportNetworkQuality, protocol.MethodDavinciCode:
// S2C Notify ignore
return nil
default:
if pkt.Kind == protocol.PacketKindInvoke {
log.Printf("[LOGIN-GATE] Unhandled Invoke MethodID %d, returning empty OK", pkt.MethodID)
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
}
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
package logingate
import (
"crypto/aes"
"crypto/cipher"
"net"
"testing"
"time"
"ananta-ps/internal/config"
"ananta-ps/pkg/crypto/chacha8"
"ananta-ps/pkg/protocol"
)
func TestLoginGateServer(t *testing.T) {
cfg := &config.Config{
Client: config.ClientConfig{
ServerID: 801,
Aid: 5944,
},
Network: config.NetworkConfig{
BindHost: "127.0.0.1",
AdvertisedHost: "127.0.0.1",
LoginPorts: []int{15200},
GamePort: 15202,
},
Player: config.PlayerConfig{
Pid: 100,
AccountId: "test_acc",
UserName: "test_user",
},
}
srv := New(cfg)
if err := srv.Start(); err != nil {
t.Fatalf("failed to start login gate: %v", err)
}
defer srv.Stop()
// Connect client
conn, err := net.DialTimeout("tcp", "127.0.0.1:15200", 2*time.Second)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
// 1. Send client handshake (mode 0x01 plaintext)
clientHandshake := make([]byte, 260)
clientHandshake[0] = 0xAA
clientHandshake[1] = 0xBB
clientHandshake[2] = 0xCC
clientHandshake[3] = 0xDD
aesKey := []byte("0123456789abcdef") // 16 bytes
copy(clientHandshake[4:20], aesKey)
if err := protocol.WriteFrame(conn, 0x01, clientHandshake, nil); err != nil {
t.Fatalf("failed to write client handshake: %v", err)
}
// 2. Read server handshake reply
serverReplyFrame, err := protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatalf("failed to read server handshake reply: %v", err)
}
if len(serverReplyFrame.Payload) < 112 {
t.Fatalf("server reply too short: %d", len(serverReplyFrame.Payload))
}
aesNonce := serverReplyFrame.Payload[8:20]
encryptedHead := serverReplyFrame.Payload[20:96]
tag := serverReplyFrame.Payload[96:112]
cipherBlock, err := aes.NewCipher(aesKey)
if err != nil {
t.Fatalf("failed to create aes cipher: %v", err)
}
aesGcm, err := cipher.NewGCM(cipherBlock)
if err != nil {
t.Fatalf("failed to create aes gcm: %v", err)
}
fullCipher := append(encryptedHead, tag...)
chaChaHead, err := aesGcm.Open(nil, aesNonce, fullCipher, nil)
if err != nil {
t.Fatalf("failed to decrypt chacha head: %v", err)
}
clientWriteKey := chaChaHead[0:32]
clientReadKey := chaChaHead[32:64]
streamNonce := chaChaHead[64:76]
clientSendCipher, err := chacha8.New(clientWriteKey, streamNonce)
if err != nil {
t.Fatalf("failed to create client send cipher: %v", err)
}
clientReadCipher, err := chacha8.New(clientReadKey, streamNonce)
if err != nil {
t.Fatalf("failed to create client read cipher: %v", err)
}
// 3. Send CheckVersion Invoke
invokePayload := protocol.BuildInvoke(protocol.MethodCheckVersion, 1, nil)
if err := protocol.WriteFrame(conn, 0x09, invokePayload, clientSendCipher); err != nil {
t.Fatalf("failed to write invoke frame: %v", err)
}
// 4. Read Return frame
respFrame, err := protocol.ReadFrame(conn, clientReadCipher)
if err != nil {
t.Fatalf("failed to read response frame: %v", err)
}
pkt := protocol.ParseRpcPacket(respFrame.Payload)
if pkt.Kind != protocol.PacketKindReturn {
t.Fatalf("expected Return packet, got %v", pkt.Kind)
}
if pkt.InvokeID != 1 {
t.Fatalf("expected InvokeID 1, got %d", pkt.InvokeID)
}
if pkt.Error != 0 {
t.Fatalf("expected Error 0, got %d", pkt.Error)
}
}
+308
View File
@@ -0,0 +1,308 @@
package web
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"sync"
"time"
"ananta-ps/internal/config"
"ananta-ps/internal/gameserver"
)
type AdminServer struct {
cfg *config.Config
gameServer *gameserver.Server
httpSrv *http.Server
cancelFunc context.CancelFunc
mu sync.RWMutex
}
func NewAdmin(cfg *config.Config, gameServer *gameserver.Server) *AdminServer {
return &AdminServer{
cfg: cfg,
gameServer: gameServer,
}
}
func (a *AdminServer) Start() error {
if !a.cfg.Admin.Enabled {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
a.cancelFunc = cancel
mux := http.NewServeMux()
mux.HandleFunc("/", a.handleDashboard)
mux.HandleFunc("/api/teleport", a.handleTeleport)
mux.HandleFunc("/api/sessions", a.handleSessions)
addr := fmt.Sprintf("%s:%d", a.cfg.Network.BindHost, a.cfg.Admin.Port)
a.httpSrv = &http.Server{
Addr: addr,
Handler: mux,
}
go func() {
log.Printf("[ADMIN-GM] Admin Web Panel running at http://%s:%d/", a.cfg.Network.AdvertisedHost, a.cfg.Admin.Port)
if err := a.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[ADMIN-GM] Web server error: %v", err)
}
}()
_ = ctx
return nil
}
func (a *AdminServer) Stop() error {
if a.cancelFunc != nil {
a.cancelFunc()
}
if a.httpSrv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = a.httpSrv.Shutdown(ctx)
}
return nil
}
func (a *AdminServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
tmpl := `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ananta GM Admin Dashboard</title>
<style>
:root {
--bg: #0f111a;
--card: #171b26;
--border: #2b3147;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #1f6feb;
--success: #238636;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 24px;
}
.container { max-width: 1200px; margin: 0 auto; }
header { margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
h1 { font-size: 24px; font-weight: 600; color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.card h2 { font-size: 18px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.form-group { margin-bottom: 12px; }
label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
input, select, button {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 14px;
}
button {
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
border: none;
margin-top: 8px;
}
button:hover { background: var(--accent-hover); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 600; background: var(--success); color: #fff; }
.stat-val { font-size: 20px; font-weight: bold; margin-top: 4px; }
.coord-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Ananta GM Admin Dashboard (Go Native)</h1>
<p style="color: var(--text-muted); font-size: 13px;">Project Mugen CBT 4229938 Sandbox Controller</p>
</div>
<div>
<span class="badge">ONLINE</span>
</div>
</header>
<div class="grid">
<!-- Server Status -->
<div class="card">
<h2>Server Status</h2>
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: var(--text-muted);">Active Sessions</div>
<div class="stat-val">{{.ActiveSessions}}</div>
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 13px; color: var(--text-muted);">Player UID / Account</div>
<div style="font-size: 14px; margin-top: 4px;">{{.PlayerPID}} ({{.AccountId}})</div>
</div>
<div>
<div style="font-size: 13px; color: var(--text-muted);">Active Character Template</div>
<div style="font-size: 14px; margin-top: 4px;">{{.InitialSpirit}}</div>
</div>
</div>
<!-- Teleportation -->
<div class="card">
<h2>Instant Teleportation</h2>
<form id="teleportForm">
<div class="form-group">
<label>Presets</label>
<select id="teleportPreset" onchange="applyPreset()">
<option value="3142.67,0.0,2522.78,0.0">Downtown Core (Spawn)</option>
<option value="3500.0,10.0,2200.0,90.0">Subway Central Station</option>
<option value="2800.0,5.0,2900.0,180.0">Harbor / Waterfront Pier</option>
<option value="4100.0,50.0,1500.0,-45.0">Highrise Skybridge</option>
<option value="1500.0,0.0,1800.0,0.0">Suburban District</option>
</select>
</div>
<div class="form-group">
<label>Coordinates (X, Y, Z)</label>
<div class="coord-grid">
<input type="number" step="0.1" id="posX" value="3142.7">
<input type="number" step="0.1" id="posY" value="0.0">
<input type="number" step="0.1" id="posZ" value="2522.8">
</div>
</div>
<div class="form-group">
<label>Facing (Yaw)</label>
<input type="number" step="0.1" id="posFacing" value="0.0">
</div>
<button type="button" onclick="submitTeleport()">Teleport In-Game</button>
</form>
<div id="teleportResult" style="margin-top: 8px; font-size: 13px;"></div>
</div>
<!-- Character & Free-Roam Controls -->
<div class="card">
<h2>Free-Roam & Abilities</h2>
<div class="form-group">
<label>Playable Character</label>
<select id="charSelect">
<option value="15020967">辰宿 (Chensu - Default)</option>
<option value="15020968">塔菲 (Taffy)</option>
<option value="15020969">莉莉 (Lily)</option>
<option value="15020970">阿兰 (Alan)</option>
<option value="15020971">黛西 (Daisy)</option>
</select>
</div>
<button type="button" onclick="alert('Character switch registered. In game press T or switch team!')">Prepare Character</button>
<div style="margin-top: 16px;">
<button type="button" style="background: #238636;" onclick="alert('Diving ability unlocked natively for all characters!')">Diving Full Unlock (3D Swim)</button>
</div>
</div>
</div>
</div>
<script>
function applyPreset() {
const parts = document.getElementById("teleportPreset").value.split(",");
document.getElementById("posX").value = parts[0];
document.getElementById("posY").value = parts[1];
document.getElementById("posZ").value = parts[2];
document.getElementById("posFacing").value = parts[3];
}
async function submitTeleport() {
const x = parseFloat(document.getElementById("posX").value);
const y = parseFloat(document.getElementById("posY").value);
const z = parseFloat(document.getElementById("posZ").value);
const facing = parseFloat(document.getElementById("posFacing").value);
const res = await fetch("/api/teleport", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({x, y, z, facing})
});
const data = await res.json();
const el = document.getElementById("teleportResult");
if (data.ok) {
el.innerText = "✓ Teleported successfully to (" + x + ", " + y + ", " + z + ")";
el.style.color = "#3fb950";
} else {
el.innerText = "✗ Error: " + (data.msg || "no active game session");
el.style.color = "#f85149";
}
}
</script>
</body>
</html>`
sessions := a.gameServer.GetActiveSessions()
data := map[string]interface{}{
"ActiveSessions": len(sessions),
"PlayerPID": a.cfg.Player.Pid,
"AccountId": a.cfg.Player.AccountId,
"InitialSpirit": a.cfg.Player.InitialSpiritTemplateId,
}
t := template.Must(template.New("dashboard").Parse(tmpl))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = t.Execute(w, data)
}
func (a *AdminServer) handleTeleport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Facing float32 `json:"facing"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "msg": err.Error()})
return
}
sessions := a.gameServer.GetActiveSessions()
if len(sessions) == 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "msg": "No active client connected in game"})
return
}
for _, sess := range sessions {
_ = sess.Teleport(req.X, req.Y, req.Z, req.Facing)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "count": len(sessions)})
}
func (a *AdminServer) handleSessions(w http.ResponseWriter, r *http.Request) {
sessions := a.gameServer.GetActiveSessions()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(sessions),
"host": a.cfg.Network.AdvertisedHost,
"port": a.cfg.Network.GamePort,
})
}
+106
View File
@@ -0,0 +1,106 @@
package web
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"ananta-ps/internal/config"
"ananta-ps/internal/gameserver"
)
type DebugServer struct {
cfg *config.Config
gameServer *gameserver.Server
httpSrv *http.Server
cancelFunc context.CancelFunc
}
func NewDebug(cfg *config.Config, gameServer *gameserver.Server) *DebugServer {
return &DebugServer{
cfg: cfg,
gameServer: gameServer,
}
}
func (d *DebugServer) Start() error {
ctx, cancel := context.WithCancel(context.Background())
d.cancelFunc = cancel
mux := http.NewServeMux()
mux.HandleFunc("/", d.handleIndex)
mux.HandleFunc("/api/status", d.handleStatus)
addr := fmt.Sprintf("%s:%d", d.cfg.Network.BindHost, 5809)
d.httpSrv = &http.Server{
Addr: addr,
Handler: mux,
}
go func() {
log.Printf("[DEBUG-API] Debug WebUI panel at http://%s:5809/", d.cfg.Network.AdvertisedHost)
if err := d.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[DEBUG-API] Debug server error: %v", err)
}
}()
_ = ctx
return nil
}
func (d *DebugServer) Stop() error {
if d.cancelFunc != nil {
d.cancelFunc()
}
if d.httpSrv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = d.httpSrv.Shutdown(ctx)
}
return nil
}
func (d *DebugServer) handleIndex(w http.ResponseWriter, r *http.Request) {
html := `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Ananta Debug WebUI</title></head>
<body style="background:#111; color:#eee; font-family:monospace; padding:20px;">
<h2>Ananta Private Server Debug Monitor (Port 5809)</h2>
<div id="status">Connecting to runtime...</div>
<pre id="info" style="margin-top:20px; background:#222; padding:15px; border-radius:5px;"></pre>
<script>
async function refresh() {
try {
const res = await fetch("/api/status");
const data = await res.json();
document.getElementById("status").innerHTML = "Status: <b>ONLINE</b> | Active Sessions: " + data.activeSessions;
document.getElementById("info").innerText = JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById("status").innerText = "Offline: " + e;
}
}
setInterval(refresh, 2000);
refresh();
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
func (d *DebugServer) handleStatus(w http.ResponseWriter, r *http.Request) {
sessions := d.gameServer.GetActiveSessions()
data := map[string]interface{}{
"online": true,
"version": d.cfg.Client.Version,
"activeSessions": len(sessions),
"advertisedHost": d.cfg.Network.AdvertisedHost,
"gamePort": d.cfg.Network.GamePort,
"loginPorts": d.cfg.Network.LoginPorts,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}