diff --git a/3DTank/Start-LocalAndRelay.ps1 b/3DTank/Start-LocalAndRelay.ps1 index 15d1f6f..4605d6d 100644 --- a/3DTank/Start-LocalAndRelay.ps1 +++ b/3DTank/Start-LocalAndRelay.ps1 @@ -49,7 +49,8 @@ param( [int]$DumpEvery = 1, # 中继:每 N 帧写一个 .bin(默认 1 = 每帧都写;0 = 不写抓包文件) [switch]$NoLog, # 中继:不写 log.txt(默认写) [switch]$NoHex, # 中继:log.txt 不写十六进制(日志体积大幅减小) - [switch]$Quiet # 中继:只写 log.txt,不刷控制台 + [switch]$Quiet, # 中继:只写 log.txt,不刷控制台 + [switch]$NoTunnel # 中继:关闭 _reconnectFast 隧道接管(默认开启:战场连接也走中继) ) $ErrorActionPreference = 'Stop' @@ -170,7 +171,7 @@ if (Test-Path $PackScript) { $RelayProc = $null if ($WithRelay) { Write-Host ("[中继] wss_server.js relay ws://localhost:{0}/ -> {1}" -f $RelayPort, $Upstream) -ForegroundColor Green - $RelayArgs = @('wss_server.js', '--port', "$RelayPort", '--upstream', $Upstream, '--upstream-alt', $UpstreamAlt, '--label', $Label, '--dump-every', "$DumpEvery") + $RelayArgs = @('wss_server.js', '--port', "$RelayPort", '--upstream', $Upstream, '--upstream-alt', $UpstreamAlt, '--label', $Label, '--dump-every', "$DumpEvery", '--tunnel-reconnect', $(if ($NoTunnel) { '0' } else { '1' })) if (-not $NoLog) { $RelayArgs += @('--log', '1') } if ($NoHex) { $RelayArgs += @('--no-hex', '1') } if ($Quiet) { $RelayArgs += @('--quiet', '1') } @@ -203,7 +204,8 @@ Write-Host '================= 已启动 =================' -ForegroundColor Yell Write-Host ("浏览器打开 : http://localhost:{0}/" -f $MirrorPort) -ForegroundColor White Write-Host ("工具链 : 默认已注入(纯净页 http://localhost:{0}/#nohornet);切勿加查询参数 ?xxx —— 会被游戏当成 config 参数,导致握手失败 1005" -f $MirrorPort) -ForegroundColor White if ($WithRelay) { - Write-Host ("连接路径 : 页面 → ws://localhost:{0}/ (中继)→ {1}" -f $RelayPort, $Upstream) -ForegroundColor White + Write-Host ("连接路径 : 页面 → ws://localhost:{0}/ (中继)→ {1}" -f $RelayPort, $Upstream) -ForegroundColor White + Write-Host ("隧道接管 : {0}" -f ($(if ($NoTunnel) {'关闭(客户端直连战场服)'} else {'开启(_reconnectFast 改写为本地,战场连接继续走中继)'}))) -ForegroundColor White $DiskInfo = if ($DumpEvery -gt 0 -or -not $NoLog) { "dump-every=$DumpEvery log=$(if ($NoLog) {'off'} else {'on'}) hex=$(if ($NoHex) {'off'} else {'on'})" } else { '不落盘(只转发 + 实时解析)' } Write-Host ("抓包目录 : {0} (仅落盘时使用)" -f $PacketDir) -ForegroundColor White Write-Host ("落盘策略 : {0} (默认落盘保存;不落盘加 -DumpEvery 0 -NoLog)" -f $DiskInfo) -ForegroundColor White diff --git a/3DTank/tools/wss_server.js b/3DTank/tools/wss_server.js index 0c533d6..37dd912 100644 --- a/3DTank/tools/wss_server.js +++ b/3DTank/tools/wss_server.js @@ -29,7 +29,7 @@ const fs = require('fs'); const http = require('http'); const path = require('path'); const zlib = require('zlib'); -const { LoadTables, MakeReader, FormatValue } = require('./lib_space_codec'); +const { LoadTables, MakeReader, ReadVarint, FormatValue } = require('./lib_space_codec'); const PROJECT = path.resolve(__dirname, '..'); const ROOT = path.resolve(PROJECT, '..'); @@ -43,6 +43,18 @@ const PORT = Number(Arg('port', '9091')); const UPSTREAM = Arg('upstream', 'wss://c2.3dtank.com:9090/'); // 主上游(大厅:首帧 = Hash Request) // 备用上游:首帧直接是 Auth/EnterSpace 的连接(复用会话哈希的"二次进空间",官方走的是另一台服务器)→ 默认 c1 const UPSTREAM_ALT = Arg('upstream-alt', 'wss://c1.3dtank.com:9090/'); +// 隧道接管:服务端在进入战斗时会下发 _reconnectFast(RemoteEndpointData{host,ports,wsProto}), +// 让客户端连到它指定的战场服(可能是另一台 c1/c2)。开启后中继会: +// ① 把该包里的 host/port 改写成 localhost:<中继端口>,让客户端继续连回中继; +// ② 记住原始 host:port,作为**下一条**客户端连接的实际上游。 +// 这样战场连接也能被抓包/解密。--tunnel-reconnect 0 可关闭(客户端将直连真实战场服)。 +const TUNNEL_RECONNECT = Arg('tunnel-reconnect', '1') !== '0'; +const RECONNECT_METHOD_ID = '4504535536312151081'; // _reconnectFast(见 names/downlink_table.json) +const RECONNECT_ID_BUF = (() => { + const B = Buffer.alloc(8); + B.writeBigInt64BE(BigInt(RECONNECT_METHOD_ID)); + return B; +})(); const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-')); const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL))); @@ -240,6 +252,131 @@ class Cipher { } } +// ============================================================ +// _reconnectFast 隧道接管(s2c 方向改包) +// ============================================================ +// 下一条客户端连接要连的上游(由服务端下发的 RemoteEndpointData 决定) +let TunnelUpstream = null; +let TunnelUpstreamAt = 0; +const TUNNEL_TTL_MS = 15000; + +function WriteVarint(N) { + const V = Number(N) | 0; + if (V < 0x80) return Buffer.from([V & 0x7f]); + if (V < 0x4000) return Buffer.from([0x80 | ((V >> 8) & 0x3f), V & 0xff]); + return Buffer.from([0xc0 | ((V >> 16) & 0x3f), (V >> 8) & 0xff, V & 0xff]); +} + +// 按 PeekFrame 的逆过程重建帧头 +function BuildFrame(Body, Compressed) { + const Size = Body.length; + const C = Compressed ? 0x40 : 0; + if (Size < 0x4000) { + return Buffer.concat([Buffer.from([C | ((Size >> 8) & 0x3f), Size & 0xff]), Body]); + } + if (Size < 0x400000) { + return Buffer.concat([Buffer.from([0x80 | C | ((Size >> 16) & 0x3f), (Size >> 8) & 0xff, Size & 0xff]), Body]); + } + const H = Buffer.alloc(4); + H[0] = 0x80 | C | ((Size >> 24) & 0x3f); + H[1] = (Size >> 16) & 0xff; + H[2] = (Size >> 8) & 0xff; + H[3] = Size & 0xff; + return Buffer.concat([H, Body]); +} + +// 解析 RemoteEndpointData:host(string) / ports(list) / wsProto(string) +function ParseEndpoint(Buf, Off) { + const H = ReadVarint(Buf, Off); + if (H.Bad) return null; + const Host = Buf.slice(H.Next, H.Next + H.Value).toString('utf8'); + let At = H.Next + H.Value; + const N = ReadVarint(Buf, At); + if (N.Bad) return null; + At = N.Next; + const Ports = []; + for (let I = 0; I < N.Value; I++) { + const R = ReadVarint(Buf, At); + if (R.Bad) return null; + Ports.push(R.Value); + At = R.Next; + } + const S = ReadVarint(Buf, At); + if (S.Bad) return null; + const Proto = Buf.slice(S.Next, S.Next + S.Value).toString('utf8'); + return { Host, Ports, Proto, Next: S.Next + S.Value }; +} + +// 在一条 s2c 明文消息里找 _reconnectFast;命中则改写成 localhost:<中继端口> 并返回新明文 +function RewriteReconnect(State, Plain) { + const Frames = ParseFrames(Plain); + if (!Frames) return null; + const Out = []; + let Changed = false; + for (const F of Frames) { + let NewBody = null; + if (!F.Compressed) { + const Hit = F.Body.indexOf(RECONNECT_ID_BUF); + if (Hit >= 0) { + const Info = ParseEndpoint(F.Body, Hit + 8); + if (Info && Info.Host && Info.Ports.length) { + const Proto = String(Info.Proto || 'WSS').toUpperCase(); + const Scheme = Proto === 'WSS' ? 'wss' : 'ws'; + const Target = Scheme + '://' + Info.Host + ':' + Info.Ports[0] + '/'; + TunnelUpstream = Target; + TunnelUpstreamAt = Date.now(); + State.Log('[_reconnectFast] 服务端指定战场服 ' + Target + ' → 已接管为下一条连接的上游'); + const Local = Buffer.from('localhost', 'utf8'); + const Ws = Buffer.from('WS', 'utf8'); + NewBody = Buffer.concat([ + F.Body.slice(0, Hit + 8), // nullmap + objectId + methodId 原样保留 + WriteVarint(Local.length), Local, + WriteVarint(1), WriteVarint(PORT), + WriteVarint(Ws.length), Ws, + ]); + Changed = true; + } else { + State.Log('[_reconnectFast] 命中但字段解不动: ' + Hex(F.Body.slice(Hit, Hit + 48), 48)); + } + } + } + Out.push(BuildFrame(NewBody || F.Body, F.Compressed ? true : false)); + } + return Changed ? Buffer.concat(Out) : null; +} + +// s2c 解密 + 可选改写。返回 { Send, FollowUp } +// 说明:改包会改变长度,本连接的密码流会与真实服务器错位;因此 FollowUp=true 时调用方 +// 需要在发完这一帧后主动关闭该连接(客户端本来也会因切服而断开)。 +function DecodeS2c(State, Decoder, Buf) { + if (Decoder.Mode !== 'cipher' || !Decoder.Cipher) { + // 明文段也可能是空间帧(少数地图/阶段),同样尝试接管 + const NewPlain = TUNNEL_RECONNECT ? RewriteReconnect(State, Buf) : null; + if (NewPlain) { + Decoder.Feed(NewPlain); + return { Send: NewPlain, FollowUp: true }; + } + Decoder.Feed(Buf); + return { Send: Buf, FollowUp: false }; + } + const Backup = new Cipher(Decoder.Key, Decoder.Encoded); + Backup.Slot.set(Decoder.Cipher.Slot); + Backup.Idx = Decoder.Cipher.Idx; + const Plain = Decoder.Cipher.Apply(Buf); + const NewPlain = TUNNEL_RECONNECT ? RewriteReconnect(State, Plain) : null; + if (!NewPlain) { + Decoder.Report(Plain, true); + return { Send: Buf, FollowUp: false }; + } + const Enc = new Cipher(Decoder.Key, Decoder.Encoded); + Enc.Slot.set(Backup.Slot); + Enc.Idx = Backup.Idx; + const NewBuf = Enc.Apply(NewPlain); + Decoder.Cipher = Enc; + Decoder.Report(NewPlain, true); + return { Send: NewBuf, FollowUp: true }; +} + function Hex(Buf, N) { return Buf.slice(0, N || 32).toString('hex'); } @@ -570,9 +707,17 @@ WsServer.on('connection', (Client, Req) => { U.on('message', (Data) => { const Buf = Buffer.from(Data); SaveFrame(State, 's2c', Buf); - Decoders.s2c.Feed(Buf); + const Res = DecodeS2c(State, Decoders.s2c, Buf); SyncKeys(Decoders); - if (Client.readyState === Ws.OPEN) Client.send(Buf); + if (Client.readyState === Ws.OPEN) Client.send(Res.Send); + if (Res.FollowUp) { + // 改包后密码流已与服务器错位;客户端本来也会切服,这里主动收尾 + State.Log('[_reconnectFast] 已改写并接管,关闭当前连接等待客户端重连'); + setTimeout(() => { + try { if (Client.readyState === Ws.OPEN) Client.close(); } catch (E) {} + try { if (U.readyState === Ws.OPEN) U.close(); } catch (E) {} + }, 200); + } }); U.on('close', (Code) => { State.Log('上游关闭 code=' + Code); @@ -581,7 +726,15 @@ WsServer.on('connection', (Client, Req) => { U.on('error', (Err) => State.Log('上游错误: ' + Err.message)); Upstream = U; }; - StartUpstream(UPSTREAM); + + // 若上一条连接收到 _reconnectFast,则本连接直接连它指定的战场服 + let InitialTarget = UPSTREAM; + if (TUNNEL_RECONNECT && TunnelUpstream && Date.now() - TunnelUpstreamAt < TUNNEL_TTL_MS) { + InitialTarget = TunnelUpstream; + TunnelUpstream = null; + State.Log('沿用 _reconnectFast 指定的上游: ' + InitialTarget); + } + StartUpstream(InitialTarget); Client.on('message', (Data) => { const Buf = Buffer.from(Data); @@ -609,5 +762,8 @@ Server.listen(PORT, () => { console.log(' 抓包目录: ' + PACKET_DIR); console.log(' 落盘策略: ' + (NeedDisk ? ('dump-every=' + DUMP_EVERY + ' log=' + (WRITE_LOG ? 'on' : 'off')) : '不落盘(只转发+实时解析;要抓包加 --dump-every 1 --log 1)')); console.log(' 转发策略: 原样直发;上游未就绪时暂存竞态帧,一连上立即按原序补发'); + console.log(' 隧道接管: ' + (TUNNEL_RECONNECT + ? '_reconnectFast → 改写 host/port 为 localhost:' + PORT + ',并按原 host 切换上游' + : '关闭(客户端会直连真实战场服)')); console.log(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压'); });