DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,335 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
testS2CNonceHex = "00112233445566778899aabbccddeeff"
|
||||
testS2CMasterKey = "ToXJkeLbeccbEN0a2fK2DjRhct6nx8KTYisGp49Levs="
|
||||
testS2CBodyHex = "0b000000ff00cafe0102030405060708090a"
|
||||
testS2CCipherHex = "e32cd1aa9c3ac15f46145c774a701eb6bfe0"
|
||||
testS2CMsgID = uint16(38)
|
||||
testS2CSeq = uint16(4242)
|
||||
testS2CTicketHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||
testS2CMachineID = "MACHINE-TEST"
|
||||
testS2CLeaseLength = 0
|
||||
)
|
||||
|
||||
// challenge 的 S2C 标记必须是**纯 opt-in**:off/shadow 一个字节都不许多,
|
||||
// 否则旧 DLL(完全不读 body 内容)之外的一切假设都要重算。
|
||||
func TestBuildLauncherAuthChallengeS2CFlagIsOptIn(t *testing.T) {
|
||||
serverIP := []byte{10, 0, 0, 1}
|
||||
nonce := make([]byte, launcherAuthNonceLen)
|
||||
|
||||
plain := buildLauncherAuthChallenge(serverIP, nonce, launcherAuthFrameVersion, false)
|
||||
if got := len(plain) - launcherAuthHeaderLen; got != launcherAuthChallengeBody {
|
||||
t.Fatalf("body len = %d, want %d", got, launcherAuthChallengeBody)
|
||||
}
|
||||
if version := plain[4]; version != launcherAuthFrameVersion {
|
||||
t.Fatalf("version = %d", version)
|
||||
}
|
||||
if bodyLen := int(binary.BigEndian.Uint16(plain[5:7])); bodyLen != launcherAuthChallengeBody {
|
||||
t.Fatalf("declared body len = %d", bodyLen)
|
||||
}
|
||||
|
||||
flagged := buildLauncherAuthChallenge(serverIP, nonce, launcherAuthFrameVersionLease, true)
|
||||
if got := len(flagged) - launcherAuthHeaderLen; got != launcherAuthChallengeBody+launcherAuthChallengeS2CFlag {
|
||||
t.Fatalf("flagged body len = %d, want %d", got, launcherAuthChallengeBody+launcherAuthChallengeS2CFlag)
|
||||
}
|
||||
if bodyLen := int(binary.BigEndian.Uint16(flagged[5:7])); bodyLen != len(flagged)-launcherAuthHeaderLen {
|
||||
t.Fatalf("declared body len = %d, actual %d", bodyLen, len(flagged)-launcherAuthHeaderLen)
|
||||
}
|
||||
if last := flagged[len(flagged)-1]; last != 1 {
|
||||
t.Fatalf("flag byte = %d, want 1", last)
|
||||
}
|
||||
// 旧客户端要求 body 长度落在 20..1024(它不读内容),21 必须仍在范围内。
|
||||
if bodyLen := len(flagged) - launcherAuthHeaderLen; bodyLen < 20 || bodyLen > 1024 {
|
||||
t.Fatalf("old clients reject body len %d", bodyLen)
|
||||
}
|
||||
}
|
||||
|
||||
// version 3 的 body 布局必须与 version 2 **完全一致**:它只是"客户端读到了 S2C 标记"的确认位。
|
||||
func TestReadLauncherAuthFrameAcceptsVersion3(t *testing.T) {
|
||||
for _, version := range []byte{launcherAuthFrameVersionLease, launcherAuthFrameVersionS2C} {
|
||||
frame := buildClientAuthFrame(t, version, "")
|
||||
server, client := net.Pipe()
|
||||
defer func() { _ = server.Close(); _ = client.Close() }()
|
||||
go func() {
|
||||
_, _ = client.Write(frame)
|
||||
}()
|
||||
parsed, err := readLauncherAuthFrame(server)
|
||||
if err != nil {
|
||||
t.Fatalf("version %d: %v", version, err)
|
||||
}
|
||||
if parsed.Version != version {
|
||||
t.Fatalf("version = %d, want %d", parsed.Version, version)
|
||||
}
|
||||
if parsed.Ticket != testS2CTicketHex {
|
||||
t.Fatalf("version %d ticket = %q", version, parsed.Ticket)
|
||||
}
|
||||
if parsed.MachineID != testS2CMachineID {
|
||||
t.Fatalf("version %d machine = %q", version, parsed.MachineID)
|
||||
}
|
||||
}
|
||||
// 未知版本仍然必须拒绝(不许猜)。
|
||||
frame := buildClientAuthFrame(t, 9, "")
|
||||
server, client := net.Pipe()
|
||||
defer func() { _ = server.Close(); _ = client.Close() }()
|
||||
go func() {
|
||||
_, _ = client.Write(frame)
|
||||
}()
|
||||
if _, err := readLauncherAuthFrame(server); err == nil {
|
||||
t.Fatal("an unknown frame version must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// 协商矩阵:只有"服务端请求(且加解密器已备好)+ 客户端回 v3"才允许加密,其余一律明文。
|
||||
func TestNegotiateGameS2CCipherMatrix(t *testing.T) {
|
||||
nonce, err := hex.DecodeString(testS2CNonceHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode nonce: %v", err)
|
||||
}
|
||||
masterKey, err := dnfproto.ParseS2CMasterKey([]byte(testS2CMasterKey))
|
||||
if err != nil {
|
||||
t.Fatalf("master key: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
requested bool
|
||||
frameVer byte
|
||||
wantCipher bool
|
||||
wantEvent string
|
||||
description string
|
||||
}{
|
||||
{"off leaves everything alone", gameS2CCipherOff, true, launcherAuthFrameVersionS2C, false, "", "off 不该产生任何事件"},
|
||||
{"shadow only observes", gameS2CCipherShadow, true, launcherAuthFrameVersionS2C, false, "game-s2c-cipher-shadow", "shadow 只记账"},
|
||||
{"on with v3 client enables", gameS2CCipherOn, true, launcherAuthFrameVersionS2C, true, "game-s2c-cipher-enabled", ""},
|
||||
{"on with legacy client stays plaintext", gameS2CCipherOn, true, launcherAuthFrameVersionLease, false, "game-s2c-cipher-skipped", "旧 DLL 不加密"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
service := &Service{options: options{gameS2CCipher: testCase.mode}}
|
||||
session := &gameSession{connID: "game-s2c-test"}
|
||||
var prepared *dnfproto.S2CCipher
|
||||
if testCase.requested {
|
||||
built, buildErr := dnfproto.NewS2CCipher(masterKey, nonce)
|
||||
if buildErr != nil {
|
||||
t.Fatalf("new cipher: %v", buildErr)
|
||||
}
|
||||
prepared = built
|
||||
}
|
||||
var events []string
|
||||
service.negotiateGameS2CCipher(session, launcherAuthFrame{Version: testCase.frameVer}, prepared,
|
||||
func(event string, _ ...any) { events = append(events, event) })
|
||||
if got := session.s2cCipher != nil; got != testCase.wantCipher {
|
||||
t.Fatalf("%s: cipher=%t want %t (%s)", testCase.name, got, testCase.wantCipher, testCase.description)
|
||||
}
|
||||
if testCase.wantEvent == "" {
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("%s: unexpected events %v", testCase.name, events)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(events) != 1 || events[0] != testCase.wantEvent {
|
||||
t.Fatalf("%s: events = %v, want [%s]", testCase.name, events, testCase.wantEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关键顺序:**加解密器必须在发 challenge 之前建好**。
|
||||
// 主密钥坏了的时候绝不能请求加密——否则客户端已按 v3 确认会解密,而服务端还在发明文,
|
||||
// 结果是全乱码(= 客户端卡住)。这条测试就钉住这个顺序。
|
||||
func TestPrepareGameS2CCipherRefusesBeforeChallenge(t *testing.T) {
|
||||
nonce := make([]byte, launcherAuthNonceLen)
|
||||
broken := &Service{options: options{
|
||||
gameS2CCipher: gameS2CCipherOn,
|
||||
gameS2CMasterKeyBase64: "not-a-valid-key",
|
||||
}}
|
||||
var events []string
|
||||
cipher, want := broken.prepareGameS2CCipher(&gameSession{}, nonce,
|
||||
func(event string, _ ...any) { events = append(events, event) })
|
||||
if cipher != nil || want {
|
||||
t.Fatal("an invalid master key must not request encryption")
|
||||
}
|
||||
if len(events) != 1 || events[0] != "game-s2c-cipher-skipped" {
|
||||
t.Fatalf("events = %v, want [game-s2c-cipher-skipped]", events)
|
||||
}
|
||||
|
||||
// 正常主密钥:备好加解密器,且此时还没有任何连接级状态被写进会话。
|
||||
healthy := &Service{options: options{gameS2CCipher: gameS2CCipherOn}}
|
||||
session := &gameSession{}
|
||||
prepared, want := healthy.prepareGameS2CCipher(session, nonce, func(string, ...any) {})
|
||||
if prepared == nil || !want {
|
||||
t.Fatal("a valid master key must prepare a cipher")
|
||||
}
|
||||
if session.s2cCipher != nil {
|
||||
t.Fatal("preparing must not attach the cipher before the client acknowledges")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端(真正走发送路径 + 加密包日志):body 变密文、长度不变、
|
||||
// checksum 覆盖**密文**、codec 标签变成 s2c-ctr-v1,且解回来与原文一致。
|
||||
func TestGameS2CEncryptsBodyAndKeepsHeaderContract(t *testing.T) {
|
||||
packetLogTestKeyEnv(t)
|
||||
anchor := filepath.Join(t.TempDir(), "packet_log.txt")
|
||||
logger, err := openPacketLogger(anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("open packet logger: %v", err)
|
||||
}
|
||||
service := &Service{}
|
||||
service.packetLog.Store(logger)
|
||||
|
||||
session := &gameSession{conn: &bufferConn{}, connID: "game-000900", stall: newDungeonStallTracker()}
|
||||
body, err := hex.DecodeString(testS2CBodyHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
nonce, err := hex.DecodeString(testS2CNonceHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode nonce: %v", err)
|
||||
}
|
||||
masterKey, err := dnfproto.ParseS2CMasterKey([]byte(testS2CMasterKey))
|
||||
if err != nil {
|
||||
t.Fatalf("master key: %v", err)
|
||||
}
|
||||
cipher, err := dnfproto.NewS2CCipher(masterKey, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
session.s2cCipher = cipher
|
||||
session.upperSeq = testS2CSeq
|
||||
|
||||
// 发送路径:会话没有 conn,写会失败——但包在写之前就构建并记进日志了。
|
||||
_ = service.sendGameUpperRawClassNoCodec(session, testS2CMsgID, append([]byte(nil), body...), 0)
|
||||
if err := logger.Close(); err != nil {
|
||||
t.Fatalf("close packet logger: %v", err)
|
||||
}
|
||||
|
||||
text := packetLogPlaintext(t, anchor)
|
||||
packet := extractSentPacket(t, text, testS2CMsgID)
|
||||
if len(packet) != dnfproto.ChannelHeaderSize+len(body) {
|
||||
t.Fatalf("packet len = %d, want %d(加密必须等长)", len(packet), dnfproto.ChannelHeaderSize+len(body))
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(packet[3:7]); int(got) != len(packet) {
|
||||
t.Fatalf("declared length = %d, actual %d", got, len(packet))
|
||||
}
|
||||
if got := binary.LittleEndian.Uint16(packet[11:13]); got != testS2CSeq {
|
||||
t.Fatalf("header seq = %d, want %d", got, testS2CSeq)
|
||||
}
|
||||
sealed := packet[dnfproto.ChannelHeaderSize:]
|
||||
if hex.EncodeToString(sealed) != testS2CCipherHex {
|
||||
t.Fatalf("wire body = %s, want %s(与客户端自检向量必须一致)", hex.EncodeToString(sealed), testS2CCipherHex)
|
||||
}
|
||||
// checksum 必须覆盖**线上字节**(seq + 密文 body),否则客户端整包拒绝。
|
||||
sum, err := dnfproto.ChecksumRange(packet, 11, len(packet)-11)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum: %v", err)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(packet[7:11]); got != sum {
|
||||
t.Fatalf("checksum = %08x, want %08x", got, sum)
|
||||
}
|
||||
plain := append([]byte(nil), sealed...)
|
||||
cipher.Apply(plain, testS2CMsgID, testS2CSeq)
|
||||
if !bytes.Equal(plain, body) {
|
||||
t.Fatalf("decrypted body = %s, want %s", hex.EncodeToString(plain), testS2CBodyHex)
|
||||
}
|
||||
if !strings.Contains(text, "body_codec="+dnfproto.S2CCipherCodecName) {
|
||||
t.Fatalf("send meta did not carry the s2c codec label:\n%s", text)
|
||||
}
|
||||
// 反向护栏:同一条日志里不许出现明文 body。
|
||||
if strings.Contains(text, "hex="+hex.EncodeToString(append(make([]byte, dnfproto.ChannelHeaderSize), body...))[:32]) {
|
||||
t.Fatal("the plaintext body leaked into the packet log")
|
||||
}
|
||||
}
|
||||
|
||||
// 没协商成功的连接必须一字不改地走明文(这是"旧客户端不炸"的地基)。
|
||||
func TestGameS2CLeavesPacketsPlaintextWithoutCipher(t *testing.T) {
|
||||
packetLogTestKeyEnv(t)
|
||||
anchor := filepath.Join(t.TempDir(), "packet_log.txt")
|
||||
logger, err := openPacketLogger(anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("open packet logger: %v", err)
|
||||
}
|
||||
service := &Service{}
|
||||
service.packetLog.Store(logger)
|
||||
session := &gameSession{conn: &bufferConn{}, connID: "game-000901", stall: newDungeonStallTracker(), upperSeq: testS2CSeq}
|
||||
body, err := hex.DecodeString(testS2CBodyHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_ = service.sendGameUpperRawClassNoCodec(session, testS2CMsgID, append([]byte(nil), body...), 0)
|
||||
if err := logger.Close(); err != nil {
|
||||
t.Fatalf("close packet logger: %v", err)
|
||||
}
|
||||
text := packetLogPlaintext(t, anchor)
|
||||
packet := extractSentPacket(t, text, testS2CMsgID)
|
||||
if got := hex.EncodeToString(packet[dnfproto.ChannelHeaderSize:]); got != testS2CBodyHex {
|
||||
t.Fatalf("body = %s, want plaintext %s", got, testS2CBodyHex)
|
||||
}
|
||||
if strings.Contains(text, dnfproto.S2CCipherCodecName) {
|
||||
t.Fatal("a plaintext connection must not be labelled with the s2c codec")
|
||||
}
|
||||
}
|
||||
|
||||
// buildClientAuthFrame 组装一个客户端 LANA 回复帧(version 2/3 布局相同)。
|
||||
func buildClientAuthFrame(t *testing.T, version byte, lease string) []byte {
|
||||
t.Helper()
|
||||
frame := []byte(launcherAuthFrameMagic)
|
||||
frame = append(frame, version)
|
||||
body := []byte(testS2CTicketHex)
|
||||
body = append(body, []byte(testS2CMachineID)...)
|
||||
body = append(body, []byte(lease)...)
|
||||
body = append(body, byte(len(lease)>>8), byte(len(lease)))
|
||||
frame = append(frame, byte(len(body)>>8), byte(len(body)))
|
||||
return append(frame, body...)
|
||||
}
|
||||
|
||||
// extractSentPacket 从(已解密的)包日志里取出某个 msg_id 的 SEND 包的原始字节。
|
||||
func extractSentPacket(t *testing.T, text string, msgID uint16) []byte {
|
||||
t.Helper()
|
||||
pattern := regexp.MustCompile(`SEND kind=game-upper[^\n]*msg_id=` + itoaUint(msgID) + `[^\n]*hex=([0-9a-f]+)`)
|
||||
match := pattern.FindStringSubmatch(text)
|
||||
if match == nil {
|
||||
t.Fatalf("no SEND packet for msg_id=%d in:\n%s", msgID, text)
|
||||
}
|
||||
packet, err := hex.DecodeString(match[1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode packet hex: %v", err)
|
||||
}
|
||||
return packet
|
||||
}
|
||||
|
||||
func itoaUint(value uint16) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var digits []byte
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
// 保证包日志里 msg_id 与 opcode 常量的绑定不漂移(dnfenum 改了要在这里发现)。
|
||||
func TestGameS2CTestFixturesMatchEnums(t *testing.T) {
|
||||
if uint16(dnfenum.CmdPacketNotifyDieMonster) != testS2CMsgID {
|
||||
t.Fatalf("fixture msg id %d no longer matches dnfenum (%d)",
|
||||
testS2CMsgID, uint16(dnfenum.CmdPacketNotifyDieMonster))
|
||||
}
|
||||
if time.Duration(0) != 0 {
|
||||
t.Fatal("unreachable")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user