feat(autotest): 新增双连接接入测试与链路密钥探测
- AutoTest:大厅 Hash Request → 取 digest/空间 id → 空间连接 EnterSpace → 收空间帧 - StreamDecoder:首条密文即穷举单字节链路密钥(K),之后 s2c 自动解密 - 首条空间帧到达后再加密发送 G.q1wd,服务端才会继续下发容器数据 - RunAutoTest.ps1:自动拉起大厅/空间两条中继,跑完自动回收 - 实测通过:握手成功、K 唯一命中、容器 1 条、回编码一致
This commit is contained in:
1 parent
2ba8df0245
commit
0ca28ff9b2
7 files changed
+566
No files matched your search
@@ -14,6 +14,8 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core"))
|
implementation(project(":core"))
|
||||||
implementation(project(":net"))
|
implementation(project(":net"))
|
||||||
|
// 自动测试用 ws 客户端:需要自定义 User-Agent(真实服务器会拒绝 Node 默认 UA)
|
||||||
|
implementation(npm("ws", "8.18.0"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// AutoTest.kt —— 接服务器自动化测试(Node 下运行)
|
||||||
|
//
|
||||||
|
// 真实链路是**两段连接**(与抓包 plain/space-* 的 c2s_0000 = EnterSpace 一致):
|
||||||
|
// ① 大厅连接(c2):Hash Request → Hash Response(digest + 分配的空间 id)
|
||||||
|
// ② 空间连接(c1):首帧直接 Auth/EnterSpace(digest, spaceId) → 开始收空间帧
|
||||||
|
//
|
||||||
|
// 环境变量:
|
||||||
|
// TANKIOFFLINE_AUTOTEST=1
|
||||||
|
// TANKIOFFLINE_AUTOTEST_URL 大厅中继,默认 ws://localhost:9091/
|
||||||
|
// TANKIOFFLINE_AUTOTEST_SPACE_URL 空间中继,默认 ws://localhost:9092/
|
||||||
|
// TANKIOFFLINE_AUTOTEST_SPACE 期望空间 id,默认 100073070(服务端会回实际分配值)
|
||||||
|
// TANKIOFFLINE_AUTOTEST_TIMEOUT 默认 20000(毫秒)
|
||||||
|
package App
|
||||||
|
|
||||||
|
import Core.ByteReader
|
||||||
|
import Core.Nullmap
|
||||||
|
import Net.Generated.DownlinkRegistry
|
||||||
|
import Net.Protocol.Entry
|
||||||
|
import Net.Protocol.EntryEvent
|
||||||
|
import Net.Protocol.S2cDecoder
|
||||||
|
import Net.Protocol.SpaceFrame
|
||||||
|
import Net.Protocol.XorCipher
|
||||||
|
|
||||||
|
// ws 包的客户端(可自定义握手头;Node 内置 WebSocket 不能改 User-Agent)
|
||||||
|
// 用 module.require 动态取构造器,避开 CommonJS/UMD 的 import 形态差异
|
||||||
|
private val CreateWs: (String, dynamic) -> dynamic =
|
||||||
|
js("(function (U, O) { var W = module.require('ws'); return new W(U, O); })")
|
||||||
|
|
||||||
|
private val MakeWsOptions: (String) -> dynamic = js(
|
||||||
|
"(function (Ua) { return { headers: { 'User-Agent': Ua }, perMessageDeflate: false }; })"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SetTimeout: (() -> Unit, Int) -> Unit = js("(function (Fn, Ms) { setTimeout(Fn, Ms); })")
|
||||||
|
|
||||||
|
private const val BrowserUserAgent =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0"
|
||||||
|
|
||||||
|
private fun Exit(Code: Int) {
|
||||||
|
js("process.exit(Code)")
|
||||||
|
}
|
||||||
|
|
||||||
|
object AutoTest {
|
||||||
|
|
||||||
|
fun Wanted(): Boolean = js("process.env.TANKIOFFLINE_AUTOTEST === '1'") as Boolean
|
||||||
|
|
||||||
|
private class Stats {
|
||||||
|
var Messages = 0
|
||||||
|
var Frames = 0
|
||||||
|
var Pings = 0
|
||||||
|
var Records = 0
|
||||||
|
var RoundTripOk = 0
|
||||||
|
var RoundTripBad = 0
|
||||||
|
var HeaderErrors = 0
|
||||||
|
var DecodeErrors = 0
|
||||||
|
var ShortCommands = 0
|
||||||
|
var UnknownMethods = 0
|
||||||
|
var Containers = 0
|
||||||
|
var HashReceived = false
|
||||||
|
var EnterSent = false
|
||||||
|
var OpenSpace = false
|
||||||
|
var SpaceConnected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Containers = setOf(
|
||||||
|
3216143066888387731L,
|
||||||
|
7640916300855664666L,
|
||||||
|
9038756763225879447L,
|
||||||
|
8179162314198150518L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Run() {
|
||||||
|
val LobbyUrl = Env("TANKIOFFLINE_AUTOTEST_URL", "ws://localhost:9091/")
|
||||||
|
val SpaceUrl = Env("TANKIOFFLINE_AUTOTEST_SPACE_URL", "ws://localhost:9092/")
|
||||||
|
val WantSpace = Env("TANKIOFFLINE_AUTOTEST_SPACE", "100073070").toLong()
|
||||||
|
val TimeoutMs = Env("TANKIOFFLINE_AUTOTEST_TIMEOUT", "20000").toInt()
|
||||||
|
|
||||||
|
val S = Stats()
|
||||||
|
val LobbyDecoder = S2cDecoder()
|
||||||
|
var SpaceDecoder: S2cDecoder? = null
|
||||||
|
var Socket: dynamic = null
|
||||||
|
var PendingDigest: ByteArray? = null
|
||||||
|
var AssignedSpace: Long? = null
|
||||||
|
var SpaceTarget = 0L
|
||||||
|
var C2sCipher: XorCipher? = null
|
||||||
|
var ObjectDataSent = false
|
||||||
|
val SpaceAliases = HashMap<Int, Pair<Long, Long>>()
|
||||||
|
|
||||||
|
println("TankiOffline 自动测试:大厅=$LobbyUrl 空间=$SpaceUrl(请求空间 $WantSpace,超时 ${TimeoutMs}ms)")
|
||||||
|
|
||||||
|
fun SendBytes(Bytes: ByteArray) {
|
||||||
|
Socket.send(Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun HandleSpaceBody(Body: ByteArray) {
|
||||||
|
val Nm = Nullmap.Size(Body, 0)
|
||||||
|
if (Nm >= Body.size) {
|
||||||
|
S.Pings++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val R = SpaceFrame.DecodeBody(Body, DownlinkRegistry.ByMethodId, SpaceAliases)
|
||||||
|
S.Records += R.Records.size
|
||||||
|
S.ShortCommands += R.ShortCommandCount
|
||||||
|
S.UnknownMethods += R.UnknownMethodIds.size
|
||||||
|
val Err = R.Error
|
||||||
|
if (Err != null) {
|
||||||
|
if (Err.startsWith("记录头截断")) S.HeaderErrors++ else S.DecodeErrors++
|
||||||
|
println(" ⚠ $Err")
|
||||||
|
}
|
||||||
|
for (Rec in R.Records) {
|
||||||
|
if (Rec.Payload.contentEquals(Rec.Reencoded)) S.RoundTripOk++ else {
|
||||||
|
S.RoundTripBad++
|
||||||
|
println(" ⚠ methodId=${Rec.MethodId} 回编码不一致")
|
||||||
|
}
|
||||||
|
if (Rec.MethodId in Containers) S.Containers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Finish() {
|
||||||
|
val Ok = S.HashReceived && S.EnterSent && S.Records > 0 && S.RoundTripBad == 0
|
||||||
|
println("—— 自动测试结果 ——")
|
||||||
|
println("消息 ${S.Messages}|帧 ${S.Frames}|保活 ${S.Pings}|空间记录 ${S.Records}")
|
||||||
|
println("回编码一致 ${S.RoundTripOk}|不一致 ${S.RoundTripBad}")
|
||||||
|
println("容器记录 ${S.Containers}|短命令 ${S.ShortCommands}|未知 methodId ${S.UnknownMethods}")
|
||||||
|
println("记录头截断 ${S.HeaderErrors}|其它解码错误 ${S.DecodeErrors}")
|
||||||
|
println("握手:Hash=${S.HashReceived} Enter=${S.EnterSent} OpenSpace=${S.OpenSpace} 空间连接=${S.SpaceConnected} 密钥=${SpaceDecoder?.Key}")
|
||||||
|
println(if (Ok) "结果:通过" else "结果:失败")
|
||||||
|
try {
|
||||||
|
Socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
Exit(if (Ok) 0 else 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开一条连接;IsSpace=true 时首帧发 EnterSpace
|
||||||
|
fun Open(Url: String, IsSpace: Boolean) {
|
||||||
|
val Decoder = if (IsSpace) S2cDecoder().also { SpaceDecoder = it } else LobbyDecoder
|
||||||
|
val Ws: dynamic = CreateWs(Url, MakeWsOptions(BrowserUserAgent))
|
||||||
|
Socket = Ws
|
||||||
|
if (IsSpace) S.SpaceConnected = true
|
||||||
|
|
||||||
|
val OnOpen: (dynamic, dynamic) -> Unit = { _, _ ->
|
||||||
|
if (IsSpace) {
|
||||||
|
val Digest = PendingDigest
|
||||||
|
val Target = AssignedSpace ?: WantSpace
|
||||||
|
if (Digest == null) {
|
||||||
|
println("✗ 空间连接缺少 digest")
|
||||||
|
Exit(1)
|
||||||
|
}
|
||||||
|
S.EnterSent = true
|
||||||
|
SpaceTarget = Target
|
||||||
|
SendBytes(Entry.BuildEnterSpace(Digest!!, Target))
|
||||||
|
println("→ [空间] Auth/EnterSpace($Target)")
|
||||||
|
} else {
|
||||||
|
println("→ [大厅] 已连接,发 Hash Request")
|
||||||
|
SendBytes(Entry.BuildHashRequest(Entry.HashKeys, Entry.HashValues))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val OnMessage: (dynamic, dynamic) -> Unit = { Data, _ ->
|
||||||
|
S.Messages++
|
||||||
|
val Bytes = js("new Int8Array(Data)") as ByteArray
|
||||||
|
val Bodies = Decoder.Feed(Bytes) { Line -> println(" $Line") }
|
||||||
|
// 第一条空间帧到达(此时密钥已知)后再发 G.q1wd;太早发服务端会直接关连接。
|
||||||
|
// 之后的上行必须用 c2s 方向的同一密钥(密钥表再 ^87)。
|
||||||
|
val K = Decoder.Key
|
||||||
|
if (IsSpace && !ObjectDataSent && K != null) {
|
||||||
|
val C = XorCipher(K, true)
|
||||||
|
C2sCipher = C
|
||||||
|
val Req = C.Apply(Entry.BuildObjectDataRequest(SpaceTarget))
|
||||||
|
SendBytes(Req)
|
||||||
|
ObjectDataSent = true
|
||||||
|
println("→ [空间] G.q1wd(1)(已加密,K=$K)")
|
||||||
|
}
|
||||||
|
for (B in Bodies) {
|
||||||
|
S.Frames++
|
||||||
|
if (IsSpace) {
|
||||||
|
HandleSpaceBody(B)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 大厅:只处理入口命令
|
||||||
|
val Nm = Nullmap.Size(B, 0)
|
||||||
|
if (Nm >= B.size) {
|
||||||
|
S.Pings++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var Reconnect = false
|
||||||
|
for (E in Entry.ParseServerBody(B)) {
|
||||||
|
when (E) {
|
||||||
|
is EntryEvent.HashResponse -> {
|
||||||
|
S.HashReceived = true
|
||||||
|
PendingDigest = E.Digest
|
||||||
|
println("← [大厅] Hash Response(加密标志=${E.Encrypted})")
|
||||||
|
}
|
||||||
|
is EntryEvent.OpenSpace -> {
|
||||||
|
S.OpenSpace = true
|
||||||
|
AssignedSpace = E.SpaceId
|
||||||
|
println("← [大厅] Open Space spaceId=${E.SpaceId}")
|
||||||
|
Reconnect = true
|
||||||
|
}
|
||||||
|
is EntryEvent.Unknown -> println("← [大厅] 未识别入口命令 id=${E.Id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Reconnect && PendingDigest != null) {
|
||||||
|
println("→ 关闭大厅连接,改连空间服务器")
|
||||||
|
try {
|
||||||
|
Ws.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
Open(SpaceUrl, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val OnError: (dynamic, dynamic) -> Unit = { _, _ -> println("✗ WebSocket 错误($Url)") }
|
||||||
|
val OnClose: (dynamic, dynamic) -> Unit = { Code, _ -> println("连接关闭 code=$Code($Url)") }
|
||||||
|
Ws.on("open", OnOpen)
|
||||||
|
Ws.on("message", OnMessage)
|
||||||
|
Ws.on("error", OnError)
|
||||||
|
Ws.on("close", OnClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
Open(LobbyUrl, false)
|
||||||
|
SetTimeout({ Finish() }, TimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EnvImpl: (String, String) -> String =
|
||||||
|
js("(function (N, D) { return process.env[N] || D; })")
|
||||||
|
|
||||||
|
private fun Env(Name: String, Def: String): String = EnvImpl(Name, Def)
|
||||||
|
}
|
||||||
@@ -122,6 +122,10 @@ private fun RunOne(Path: String, Registry: Map<Long, ErasedCodec>, S: Stats) {
|
|||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
if (!IsNode()) return
|
if (!IsNode()) return
|
||||||
|
if (AutoTest.Wanted()) {
|
||||||
|
AutoTest.Run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下)
|
// 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下)
|
||||||
val Root = js(
|
val Root = js(
|
||||||
|
|||||||
@@ -42,6 +42,11 @@
|
|||||||
"Module": "94965",
|
"Module": "94965",
|
||||||
"Evidence": "module_94965.js:305 var e = \"1.13.6\"",
|
"Evidence": "module_94965.js:305 var e = \"1.13.6\"",
|
||||||
"Confidence": "已证实"
|
"Confidence": "已证实"
|
||||||
|
},
|
||||||
|
"ws": {
|
||||||
|
"Version": "8.18.0",
|
||||||
|
"Evidence": "仅自动测试客户端使用:Node 内置 WebSocket 无法自定义 User-Agent,真实服务器会拒绝 node UA",
|
||||||
|
"Confidence": "测试依赖"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"KotlinEcosystem": {
|
"KotlinEcosystem": {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Entry.kt —— 入口层(握手)命令
|
||||||
|
//
|
||||||
|
// 依据:3DTank/tools/server/entry.js 与 module_61860 的入口帧处理。
|
||||||
|
// c2s 1 Hash Request 体 = [1][varint N][N × 字符串][varint M][M × 字符串]
|
||||||
|
// s2c 2 Hash Response 体 = [2][32 字节 hash][1 字节 是否启用链路加密]
|
||||||
|
// c2s 3 Auth/EnterSpace 体 = [3][32 字节 digest][i64 spaceId]
|
||||||
|
// s2c 32 Open Space 体 = [32][i64 spaceId]
|
||||||
|
package Net.Protocol
|
||||||
|
|
||||||
|
import Core.ByteReader
|
||||||
|
import Core.ByteWriter
|
||||||
|
import Core.Frame
|
||||||
|
import Core.Nullmap
|
||||||
|
import Core.ProtocolException
|
||||||
|
|
||||||
|
sealed class EntryEvent {
|
||||||
|
class HashResponse(val Digest: ByteArray, val Encrypted: Boolean) : EntryEvent()
|
||||||
|
class OpenSpace(val SpaceId: Long) : EntryEvent()
|
||||||
|
class Unknown(val Id: Int, val Remaining: ByteArray) : EntryEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
object Entry {
|
||||||
|
|
||||||
|
const val C2S_HASH_REQUEST = 1
|
||||||
|
const val S2C_HASH_RESPONSE = 2
|
||||||
|
const val C2S_ENTER_SPACE = 3
|
||||||
|
const val S2C_OPEN_SPACE = 32
|
||||||
|
|
||||||
|
fun BuildHashRequest(Keys: List<String>, Values: List<String>): ByteArray {
|
||||||
|
val W = ByteWriter()
|
||||||
|
W.WriteByte(C2S_HASH_REQUEST)
|
||||||
|
W.WriteVarint(Keys.size)
|
||||||
|
for (K in Keys) W.WriteString(K)
|
||||||
|
W.WriteVarint(Values.size)
|
||||||
|
for (V in Values) W.WriteString(V)
|
||||||
|
return Frame.EncodeFrame(W.ToByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BuildEnterSpace(Digest: ByteArray, SpaceId: Long): ByteArray {
|
||||||
|
if (Digest.size != 32) throw ProtocolException("EnterSpace 的 digest 必须是 32 字节,实际 ${Digest.size}")
|
||||||
|
val W = ByteWriter()
|
||||||
|
W.WriteByte(C2S_ENTER_SPACE)
|
||||||
|
W.WriteRaw(Digest)
|
||||||
|
W.WriteLong(SpaceId)
|
||||||
|
return Frame.EncodeFrame(W.ToByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空间连接建立后客户端要立即发的一条请求(G.q1wd):
|
||||||
|
// 抓包 space-100045260/c2s_0001 = [nullmap 00][objectId i64][methodId i64][i32 1]
|
||||||
|
// 不发这条,服务端只推一帧就正常关闭(code=1000)。
|
||||||
|
fun BuildObjectDataRequest(SpaceId: Long, Index: Int = 1): ByteArray {
|
||||||
|
val W = ByteWriter()
|
||||||
|
W.WriteLong(SpaceId)
|
||||||
|
W.WriteLong(1816792453857564692L)
|
||||||
|
W.WriteInt(Index)
|
||||||
|
return Frame.EncodeFrame(W.ToByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个入口帧体(nullmap + 若干入口命令)→ 事件列表
|
||||||
|
fun ParseServerBody(Body: ByteArray): List<EntryEvent> {
|
||||||
|
val Out = ArrayList<EntryEvent>()
|
||||||
|
val Start = Nullmap.Size(Body, 0)
|
||||||
|
if (Start <= 0 || Start >= Body.size) return Out
|
||||||
|
val R = ByteReader(Body, Start)
|
||||||
|
while (R.Remaining > 0) {
|
||||||
|
val Id = R.ReadByte().toInt() and 0xff
|
||||||
|
when (Id) {
|
||||||
|
S2C_HASH_RESPONSE -> {
|
||||||
|
if (R.Remaining < 33) {
|
||||||
|
Out.add(EntryEvent.Unknown(Id, ByteArray(0)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val Digest = R.ReadRaw(32)
|
||||||
|
val Flag = R.ReadByte().toInt() and 0xff
|
||||||
|
Out.add(EntryEvent.HashResponse(Digest, Flag != 0))
|
||||||
|
}
|
||||||
|
S2C_OPEN_SPACE -> {
|
||||||
|
if (R.Remaining < 8) {
|
||||||
|
Out.add(EntryEvent.Unknown(Id, ByteArray(0)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Out.add(EntryEvent.OpenSpace(R.ReadLong()))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Out.add(EntryEvent.Unknown(Id, ByteArray(0)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与真实抓包一致的握手键/值;client_version 用当前基线 e98d0216
|
||||||
|
val HashKeys = listOf(
|
||||||
|
"relay", "resources", "config-template", "lang", "browser_user_agent", "os", "device_type",
|
||||||
|
"client_type", "hardware_concurrency", "time_zone", "video_card", "mobile_device",
|
||||||
|
"touchscreen_support", "client_version", "ram_size",
|
||||||
|
)
|
||||||
|
|
||||||
|
val HashValues = listOf(
|
||||||
|
"0",
|
||||||
|
"http://localhost:8080/resources",
|
||||||
|
"http://localhost:8080/config.xml",
|
||||||
|
"cn",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0",
|
||||||
|
"Win32",
|
||||||
|
"Desktop",
|
||||||
|
"BROWSER",
|
||||||
|
"16",
|
||||||
|
"Asia/Shanghai",
|
||||||
|
"ANGLE (AMD, AMD Radeon(TM) 860M Graphics (0x00001114) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||||
|
"false",
|
||||||
|
"false",
|
||||||
|
"e98d02161a563e043eabb96c6e4db5fedcf165e7",
|
||||||
|
"32768",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// StreamDecoder.kt —— s2c 方向的链路解码:明文段 → 密钥探测 → 自变体 XOR 流密码
|
||||||
|
//
|
||||||
|
// 依据:3DTank/tools/wss_server.js 的 StreamDecoder/Cipher 与 module_61860 的链路编码器:
|
||||||
|
// 解密:p = c ^ key[idx];key[idx] = p;idx ^= (p & 7)
|
||||||
|
// key[i] = K ^ (i << 3)(s2c);c2s 再 ^ 87
|
||||||
|
// Open Space 之后服务端切加密;密钥是单字节,用「解密后每条消息都必须是完整帧」来穷举。
|
||||||
|
package Net.Protocol
|
||||||
|
|
||||||
|
import Core.Frame
|
||||||
|
|
||||||
|
class XorCipher(K: Int, Encoded: Boolean) {
|
||||||
|
|
||||||
|
private val Slot = IntArray(8) { I -> ((K xor (I shl 3)) xor (if (Encoded) 87 else 0)) and 0xff }
|
||||||
|
private var Index = 0
|
||||||
|
|
||||||
|
fun Apply(Buf: ByteArray): ByteArray {
|
||||||
|
val Out = ByteArray(Buf.size)
|
||||||
|
for (I in Buf.indices) {
|
||||||
|
val Plain = (Buf[I].toInt() xor Slot[Index]) and 0xff
|
||||||
|
Out[I] = Plain.toByte()
|
||||||
|
Slot[Index] = Plain
|
||||||
|
Index = Index xor (Plain and 7)
|
||||||
|
}
|
||||||
|
return Out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class S2cDecoder {
|
||||||
|
|
||||||
|
private enum class Stage { Plain, Probing, Cipher }
|
||||||
|
|
||||||
|
private var Current: Stage = Stage.Plain
|
||||||
|
private var Cipher: XorCipher? = null
|
||||||
|
private val Window = ArrayList<ByteArray>()
|
||||||
|
|
||||||
|
val Key: Int? get() = if (Current == Stage.Cipher) LastKey else null
|
||||||
|
private var LastKey: Int? = null
|
||||||
|
|
||||||
|
// 返回解压后的帧体;处于密钥探测窗口内时返回空
|
||||||
|
fun Feed(Buf: ByteArray, Log: (String) -> Unit): List<ByteArray> {
|
||||||
|
when (Current) {
|
||||||
|
Stage.Plain -> {
|
||||||
|
val Frames = Frame.ParseFrames(Buf)
|
||||||
|
if (Frames != null) return DecodeFrames(Frames, Log)
|
||||||
|
Current = Stage.Probing
|
||||||
|
Window.clear()
|
||||||
|
Window.add(Buf)
|
||||||
|
Log("明文段结束,开始探测链路密钥(首条密文 ${Buf.size}B)")
|
||||||
|
// 关键:首条密文就要立刻穷举,否则服务端在等回复时会直接关连接
|
||||||
|
return Probe(Log)
|
||||||
|
}
|
||||||
|
Stage.Probing -> {
|
||||||
|
Window.add(Buf)
|
||||||
|
return Probe(Log)
|
||||||
|
}
|
||||||
|
Stage.Cipher -> {
|
||||||
|
val Plain = Cipher!!.Apply(Buf)
|
||||||
|
val Frames = Frame.ParseFrames(Plain)
|
||||||
|
if (Frames == null) {
|
||||||
|
Log("密文帧解析失败(${Buf.size}B)")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return DecodeFrames(Frames, Log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在探测窗口上穷举单字节密钥;唯一命中即切到密文态并吐出窗口内所有帧
|
||||||
|
private fun Probe(Log: (String) -> Unit): List<ByteArray> {
|
||||||
|
val Hits = (0..255).filter { SeriesOk(Window, it) }
|
||||||
|
if (Hits.size == 1) {
|
||||||
|
val K = Hits[0]
|
||||||
|
LastKey = K
|
||||||
|
Cipher = XorCipher(K, false)
|
||||||
|
Current = Stage.Cipher
|
||||||
|
Log("链路密钥 K=$K(窗口 ${Window.size} 条)")
|
||||||
|
val Buffered = ArrayList(Window)
|
||||||
|
Window.clear()
|
||||||
|
val Out = ArrayList<ByteArray>()
|
||||||
|
for (B in Buffered) Out.addAll(DecodeFrames(Frame.ParseFrames(Cipher!!.Apply(B)) ?: emptyList(), Log))
|
||||||
|
return Out
|
||||||
|
}
|
||||||
|
val Bytes = Window.sumOf { it.size }
|
||||||
|
if (Window.size >= 8 || Bytes > 262144) {
|
||||||
|
Log("密钥探测失败(候选 ${Hits.size} 个),后续按非帧处理")
|
||||||
|
Current = Stage.Plain
|
||||||
|
val Buffered = ArrayList(Window)
|
||||||
|
Window.clear()
|
||||||
|
val Out = ArrayList<ByteArray>()
|
||||||
|
for (B in Buffered) {
|
||||||
|
val F = Frame.ParseFrames(B) ?: continue
|
||||||
|
Out.addAll(DecodeFrames(F, Log))
|
||||||
|
}
|
||||||
|
return Out
|
||||||
|
}
|
||||||
|
Log("密钥候选 ${Hits.size} 个,继续等待下一条")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一密钥连续作用于窗口内所有消息(流密码状态连续)
|
||||||
|
private fun SeriesOk(Window: List<ByteArray>, K: Int): Boolean {
|
||||||
|
val C = XorCipher(K, false)
|
||||||
|
for (B in Window) {
|
||||||
|
if (Frame.ParseFrames(C.Apply(B)) == null) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DecodeFrames(Frames: List<Core.WireFrame>, Log: (String) -> Unit): List<ByteArray> {
|
||||||
|
val Out = ArrayList<ByteArray>(Frames.size)
|
||||||
|
for (F in Frames) {
|
||||||
|
try {
|
||||||
|
Out.add(Frame.DecodeFrame(F))
|
||||||
|
} catch (E: Throwable) {
|
||||||
|
Log("帧解压失败:${E.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# RunAutoTest.ps1 —— 启动两条中继并跑 TankiOffline 接入测试
|
||||||
|
#
|
||||||
|
# 真实链路是两段连接:
|
||||||
|
# 大厅:9091 → wss://c2.3dtank.com:9090/(Hash Request / Hash Response)
|
||||||
|
# 空间:9092 → wss://c2.3dtank.com:9090/(首帧 EnterSpace → 空间帧;实测 c2 才会回空间数据)
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File .\tools\RunAutoTest.ps1
|
||||||
|
# powershell -ExecutionPolicy Bypass -File .\tools\RunAutoTest.ps1 -TimeoutSec 30 -SpaceId 100073070
|
||||||
|
param(
|
||||||
|
[long]$SpaceId = 100073070,
|
||||||
|
[int]$TimeoutSec = 25,
|
||||||
|
[int]$LobbyPort = 9091,
|
||||||
|
[int]$SpacePort = 9092,
|
||||||
|
[string]$LobbyUpstream = 'wss://c2.3dtank.com:9090/',
|
||||||
|
[string]$SpaceUpstream = 'wss://c2.3dtank.com:9090/'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$Tools = Join-Path (Split-Path -Parent $Root) '3DTank\tools'
|
||||||
|
$RelayLobbyOut = Join-Path $env:TEMP 'tankioffline-relay-lobby.out.log'
|
||||||
|
$RelayLobbyErr = Join-Path $env:TEMP 'tankioffline-relay-lobby.err.log'
|
||||||
|
$RelaySpaceOut = Join-Path $env:TEMP 'tankioffline-relay-space.out.log'
|
||||||
|
$RelaySpaceErr = Join-Path $env:TEMP 'tankioffline-relay-space.err.log'
|
||||||
|
|
||||||
|
if (-not (Test-Path (Join-Path $Tools 'wss_server.js'))) {
|
||||||
|
throw "找不到中继脚本:$Tools\wss_server.js"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-Relay([int]$Port, [string]$Upstream, [string]$Out, [string]$Err, [string]$Label) {
|
||||||
|
$Args = @(
|
||||||
|
'wss_server.js',
|
||||||
|
'--port', "$Port",
|
||||||
|
'--upstream', $Upstream,
|
||||||
|
'--dump-every', '0',
|
||||||
|
'--log', '0',
|
||||||
|
'--quiet', '1',
|
||||||
|
'--tunnel-reconnect', '0',
|
||||||
|
'--label', $Label
|
||||||
|
)
|
||||||
|
Write-Host "启动中继 $Label:$Upstream → localhost:$Port"
|
||||||
|
return Start-Process -FilePath 'node' -ArgumentList $Args -WorkingDirectory $Tools -PassThru -WindowStyle Hidden `
|
||||||
|
-RedirectStandardOutput $Out -RedirectStandardError $Err
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Port([int]$Port, [string]$Name) {
|
||||||
|
for ($I = 0; $I -lt 50; $I++) {
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
try {
|
||||||
|
$Client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
$Client.Connect('127.0.0.1', $Port)
|
||||||
|
$Client.Close()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "$Name 端口 $Port 未启动"
|
||||||
|
}
|
||||||
|
|
||||||
|
$RelayLobby = Start-Relay $LobbyPort $LobbyUpstream $RelayLobbyOut $RelayLobbyErr 'autotest-lobby'
|
||||||
|
$RelaySpace = Start-Relay $SpacePort $SpaceUpstream $RelaySpaceOut $RelaySpaceErr 'autotest-space'
|
||||||
|
|
||||||
|
try {
|
||||||
|
Wait-Port $LobbyPort '大厅中继'
|
||||||
|
Wait-Port $SpacePort '空间中继'
|
||||||
|
|
||||||
|
$env:TANKIOFFLINE_AUTOTEST = '1'
|
||||||
|
$env:TANKIOFFLINE_AUTOTEST_URL = "ws://localhost:$LobbyPort/"
|
||||||
|
$env:TANKIOFFLINE_AUTOTEST_SPACE_URL = "ws://localhost:$SpacePort/"
|
||||||
|
$env:TANKIOFFLINE_AUTOTEST_SPACE = "$SpaceId"
|
||||||
|
$env:TANKIOFFLINE_AUTOTEST_TIMEOUT = "$($TimeoutSec * 1000)"
|
||||||
|
|
||||||
|
Write-Host "运行客户端:大厅 $($env:TANKIOFFLINE_AUTOTEST_URL) / 空间 $($env:TANKIOFFLINE_AUTOTEST_SPACE_URL)"
|
||||||
|
& (Join-Path $Root 'gradlew.bat') ':app:jsNodeDevelopmentRun' '--console=plain'
|
||||||
|
$Code = $LASTEXITCODE
|
||||||
|
Write-Host "客户端退出码:$Code"
|
||||||
|
exit $Code
|
||||||
|
} finally {
|
||||||
|
foreach ($P in @($RelayLobby, $RelaySpace)) {
|
||||||
|
if ($P -and -not $P.HasExited) {
|
||||||
|
Stop-Process -Id $P.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host '中继已停止'
|
||||||
|
}
|
||||||
Reference in new issue
Block a user