- serve.js:新增 --config-upstream(默认 https://c2.3dtank.com),/config.xml 回源与改写底稿都改用它 - wss_server.js / Start-LocalAndRelay.ps1:上游默认 c1 改为 c2,可用 -Upstream / --upstream 切回 - 依据:客户端 JS 无 c1/c2 硬编码(连谁完全由 config 决定);实测 c1/c2 各自提供指向自己的 config,官方客户端实际连 c2
601 lines
25 KiB
JavaScript
601 lines
25 KiB
JavaScript
// wss_server.js - 本地游戏通信服务器(替代 wss://c1.3dtank.com:9090/)
|
||
// 用法:
|
||
// node wss_server.js [--port 9091] [--upstream wss://c1.3dtank.com:9090/] [--label 名字]
|
||
// 模式:
|
||
// relay 中继: 客户端 → 本地 → 真实服务器。抓包落盘 + 实时解析:
|
||
// 明文段直接解命令;进入加密段后自动穷举 1 字节密钥并后续实时解密;
|
||
// 帧头 0x40 位为 zlib 压缩,自动解压后再解命令。
|
||
// 挂载: 客户端 config.xml 指向本服务器(local/config.xml + serve.js 的 /config.xml 本地覆盖)
|
||
// <server address="localhost" wsProtocol="WS"> + <port>9091</port> → ws://localhost:9091/
|
||
// 产物: 3DTank/packets/<label>/session_00N/{log.txt, c2s_%04d.bin, s2c_%04d.bin}
|
||
// 注意:默认**落盘保存**(每帧一个 .bin + log.txt,统一走有序异步写队列,不阻塞转发);
|
||
// 要纯转发不落盘请加 --dump-every 0 --log 0
|
||
//
|
||
// 协议(逆向自 module_61860 + sockets/):
|
||
// 帧 = [2 或 4 字节大端长度][帧体];b0 & 0x80 → 4 字节头;b0 & 0x40 → 帧体 zlib 压缩
|
||
// 帧体 = [nullmap][payload];payload = [1 字节命令 ID][body]
|
||
// 链路编码器: 明文 nr(直接拷贝)/ 加密 ir(自变体 XOR 流密码)
|
||
// 解密: p = c ^ key[idx]; key[idx] = p; idx ^= (p & 7)
|
||
// key[i] = K ^ (i << 3)(下发方向);上传方向再 ^ 87;K 为单字节
|
||
// Open Space 之后切换加密,故抓包里明文/密文混排
|
||
//
|
||
// 上行命令识别(默认开,--show-uplink 0 关闭):
|
||
// c2s 空间帧的正文 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…]
|
||
// 出处:module_61860 的 SpaceCommand codec —— fr.o7g 依次写 f2u(objectId)、f2u(methodId),
|
||
// 之后才是命令自身负载;or.o7g = 先写命令 id 再写该命令注册的 codec。
|
||
// methodId 查 names/uplink_table.json → 命令类 / 发送方法;负载优先用 names/codec_fields.json 的字段名逐字段解。
|
||
// 解不动就停在原地并把剩余字节原样打出来(不做宽度猜测)。
|
||
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 PROJECT = path.resolve(__dirname, '..');
|
||
const ROOT = path.resolve(PROJECT, '..');
|
||
const Argv = process.argv.slice(2);
|
||
const Arg = (Name, Def) => {
|
||
const I = Argv.indexOf('--' + Name);
|
||
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
|
||
};
|
||
|
||
const PORT = Number(Arg('port', '9091'));
|
||
const UPSTREAM = Arg('upstream', 'wss://c2.3dtank.com:9090/'); // 官方客户端实际连 c2(c1/c2 是两个独立服;要切回来用 --upstream wss://c1.3dtank.com:9090/)
|
||
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
|
||
const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL)));
|
||
|
||
// 落盘/日志策略:**默认落盘保存**(统一走有序异步写队列,绝不阻塞转发路径)
|
||
// --dump-every N 写 .bin 抓包文件(每 N 帧一个;默认 1 = 每帧都写;0 = 不写)
|
||
// --log 1 写 log.txt(默认 1 = 写)
|
||
// --no-hex 1 log.txt 不写十六进制
|
||
// --quiet 1 只写 log.txt,不刷控制台
|
||
const DUMP_EVERY = Number(Arg('dump-every', '1'));
|
||
const WRITE_LOG = Arg('log', '1') === '1';
|
||
const WRITE_HEX = Arg('no-hex', '0') !== '1';
|
||
const QUIET = Arg('quiet', '0') === '1';
|
||
const NeedDisk = DUMP_EVERY > 0 || WRITE_LOG;
|
||
|
||
// 转发策略:上游就绪 → 客户端帧**原样直发**;未就绪(握手帧常比上游 TLS 建连更早到达)→
|
||
// 暂存于进程内 Pending,上游一连上立即按原序补发。不重放、不改字节、不加延迟。
|
||
|
||
// ===== 崩溃可见性 & 稳定性 =====
|
||
// 未捕获异常/未处理拒绝一律打印完整堆栈并**保持进程存活**,避免长对局中途"后端悄悄崩了";
|
||
// 需要看现场时窗口也不会关(ps1 用 cmd /k 启动)。
|
||
process.on('uncaughtException', (Err) => {
|
||
console.error('[崩溃] 未捕获异常:', (Err && Err.stack) || Err);
|
||
});
|
||
process.on('unhandledRejection', (Err) => {
|
||
console.error('[崩溃] 未处理的 Promise 拒绝:', (Err && Err.stack) || Err);
|
||
});
|
||
|
||
// 字段表:names/codec_fields.json(codec 字段)+ names/uplink_table.json(上行命令名 / 负载字段)
|
||
const Tables = LoadTables(path.join(PROJECT, 'names'));
|
||
const Reader = MakeReader(Tables, { EnumBytes: 4 });
|
||
const ShowUplink = Arg('show-uplink', '1') !== '0';
|
||
// methodId(8 字节大端 hex) → 上行命令(用于在上行帧里认 methodId,以及给应答签名用)
|
||
const UplinkByHex = new Map();
|
||
for (const [MethodId, Cmd] of Tables.UplinkByMethod) {
|
||
const B = Buffer.alloc(8);
|
||
B.writeBigInt64BE(BigInt(MethodId));
|
||
UplinkByHex.set(B.toString('hex'), Cmd);
|
||
}
|
||
|
||
// ws 模块:优先当前目录解析,回落到 sockets/(唯一装过 ws 的地方)
|
||
function RequireWs() {
|
||
try {
|
||
return require('ws');
|
||
} catch (Err) {
|
||
return require(path.join(ROOT, 'sockets', 'node_modules', 'ws'));
|
||
}
|
||
}
|
||
const Ws = RequireWs();
|
||
|
||
// 上游握手头:真实服务器会校验来源
|
||
// 注意 Origin 与 Referer 的差别(对齐真实浏览器在 https://3dtank.com/play/ 下的行为):
|
||
// Origin 按规范**只到源**,不带路径 → https://3dtank.com
|
||
// Referer 是完整地址,带路径 → https://3dtank.com/play/
|
||
const UPSTREAM_HEADERS = {
|
||
'user-agent':
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
|
||
origin: 'https://3dtank.com',
|
||
referer: 'https://3dtank.com/play/',
|
||
};
|
||
|
||
// 命令名(入口层已从源码确证;ID 可跨方向复用,故按方向区分)
|
||
const CMD_NAME = {
|
||
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace', 32: 'ClientLog' },
|
||
s2c: { 2: 'Hash Response', 32: 'Open Space' },
|
||
};
|
||
|
||
// ============================================================
|
||
// 帧 / nullmap / 密码
|
||
// ============================================================
|
||
|
||
function PeekFrame(Buf) {
|
||
if (Buf.length < 2) return null;
|
||
const B0 = Buf[0];
|
||
if (B0 & 0x80) {
|
||
if (Buf.length < 4) return null;
|
||
return {
|
||
TotalSize: 4 + ((((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0),
|
||
HeaderSize: 4,
|
||
Compressed: (B0 & 0x40) !== 0,
|
||
};
|
||
}
|
||
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[1]), HeaderSize: 2, Compressed: (B0 & 0x40) !== 0 };
|
||
}
|
||
|
||
// 拆出一整串帧;有任何残余则视为「不是帧」返回 null
|
||
function ParseFrames(Buf) {
|
||
const Frames = [];
|
||
let Off = 0;
|
||
while (Off < Buf.length) {
|
||
const Info = PeekFrame(Buf.slice(Off));
|
||
if (!Info || Off + Info.TotalSize > Buf.length) return null;
|
||
Frames.push({ Body: Buf.slice(Off + Info.HeaderSize, Off + Info.TotalSize), Compressed: Info.Compressed });
|
||
Off += Info.TotalSize;
|
||
}
|
||
return Frames.length ? Frames : null;
|
||
}
|
||
|
||
// nullmap 长度(对应 x7g)
|
||
function NullmapSize(Buf, Offset) {
|
||
const R = Buf[Offset];
|
||
if (R === undefined) return 0;
|
||
if ((R & 0x80) !== 0) {
|
||
if (R & 0x40) return 4 + (((R & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]);
|
||
return 1 + (R & 0x3f);
|
||
}
|
||
return 1 + ((R & 0x60) >>> 5);
|
||
}
|
||
|
||
// ============================================================
|
||
// 上行命令识别(c2s 空间帧)
|
||
// ============================================================
|
||
// 帧体 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…](出处见文件头注释)
|
||
// 逐条记录查 names/uplink_table.json;负载字段名优先取 codec_fields.json(按 methodId 反查 codec)
|
||
// 返回 null 表示这条帧不是空间命令(如握手帧、保活帧)
|
||
// 首字节是 objectId 的最高字节(实测 0x00 = 小正数 id,0xff = 负数 id);握手/日志帧的首字节是命令 id
|
||
// (1=Hash Request、3=Auth、32=ClientLog),且 At+8 处必须命中上行命令表,两道门一起挡住误判。
|
||
function SpaceMethodId(Body, At) {
|
||
const B0 = Body[At];
|
||
if (B0 !== 0x00 && B0 !== 0xff) return null;
|
||
if (At + 16 > Body.length) return null;
|
||
const Hex = Body.slice(At + 8, At + 16).toString('hex');
|
||
return UplinkByHex.has(Hex) ? Body.readBigInt64BE(At + 8).toString() : null;
|
||
}
|
||
|
||
function DescribeUplink(Body, At) {
|
||
if (!SpaceMethodId(Body, At)) return null;
|
||
const ObjectId = Body.readBigInt64BE(At).toString();
|
||
const Records = [];
|
||
let Off = At + 8;
|
||
while (Off + 8 <= Body.length) {
|
||
const Hex = Body.slice(Off, Off + 8).toString('hex');
|
||
const Cmd = UplinkByHex.get(Hex);
|
||
if (!Cmd) break;
|
||
const MethodId = Body.readBigInt64BE(Off).toString();
|
||
Off += 8;
|
||
const Codec = Tables.CodecByMethod.get(MethodId);
|
||
const Fields = Codec
|
||
? Codec.Fields
|
||
: (Cmd.Fields || []).map((F) => ({ Name: F.Field, Type: F.Type, TypeCodec: F.TypeCodec || '' }));
|
||
const Values = [];
|
||
let Bad = false;
|
||
for (const F of Fields) {
|
||
const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0);
|
||
Values.push([F.Name || '(匿名)', F.Type, R.Value]);
|
||
if (R.Bad || R.Next <= Off) {
|
||
Bad = true;
|
||
break;
|
||
}
|
||
Off = R.Next;
|
||
}
|
||
Records.push({
|
||
MethodId,
|
||
Name: (Cmd.OwnerName || Cmd.OwnerClass || '?') + '.' + (Cmd.SendFn || '?'),
|
||
Module: Cmd.Module,
|
||
Values,
|
||
Bad,
|
||
});
|
||
}
|
||
if (!Records.length) return null;
|
||
return { ObjectId, Records, Next: Off };
|
||
}
|
||
|
||
// 把 DescribeUplink 的结果渲染成日志行
|
||
function FormatUplink(Info, Body) {
|
||
const Lines = [' 上行 objectId=' + Info.ObjectId + ' 命令 ' + Info.Records.length + ' 条'];
|
||
for (const R of Info.Records) {
|
||
Lines.push(' ' + R.Name + ' (' + R.Module + ') methodId=' + R.MethodId);
|
||
for (const [K, T, V] of R.Values)
|
||
Lines.push(' ' + String(K).padEnd(22) + ' ' + String(T).padEnd(14) + ' = ' + FormatValue(V));
|
||
if (R.Bad) Lines.push(' <负载解不动,剩余原始字节: ' + Hex(Body.slice(Info.Next), 32) + '>');
|
||
}
|
||
return Lines;
|
||
}
|
||
|
||
// 自变体 XOR 流密码(module_61860 类 ir)
|
||
class Cipher {
|
||
constructor(K, Encoded) {
|
||
this.Slot = new Int32Array(8);
|
||
for (let I = 0; I < 8; I++) this.Slot[I] = ((K ^ (I << 3)) ^ (Encoded ? 87 : 0)) & 0xff;
|
||
this.Idx = 0;
|
||
}
|
||
Apply(Buf) {
|
||
const Out = Buffer.alloc(Buf.length);
|
||
for (let I = 0; I < Buf.length; I++) {
|
||
const Plain = (Buf[I] ^ this.Slot[this.Idx]) & 0xff;
|
||
Out[I] = Plain;
|
||
this.Slot[this.Idx] = Plain;
|
||
this.Idx = this.Idx ^ (Plain & 7);
|
||
}
|
||
return Out;
|
||
}
|
||
static Series(Window, K, Encoded) {
|
||
const C = new Cipher(K, Encoded);
|
||
return Window.map((Buf) => C.Apply(Buf));
|
||
}
|
||
}
|
||
|
||
function Hex(Buf, N) {
|
||
return Buf.slice(0, N || 32).toString('hex');
|
||
}
|
||
function Ascii(Buf, N) {
|
||
return Array.from(Buf.slice(0, N || 40))
|
||
.map((B) => (B >= 32 && B < 127 ? String.fromCharCode(B) : '.'))
|
||
.join('');
|
||
}
|
||
|
||
// 变长整数长度前缀的字符串(x7g 家族),返回 { Str, Next }
|
||
function ReadLogString(Buf, Offset) {
|
||
let Len = Buf[Offset];
|
||
let Next = Offset + 1;
|
||
if (Len & 0x80) {
|
||
if (Len & 0x40) {
|
||
Len = (((Len & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]) >>> 0;
|
||
Next = Offset + 3;
|
||
} else {
|
||
Len = ((Len & 0x3f) << 8) | Buf[Offset + 1];
|
||
Next = Offset + 2;
|
||
}
|
||
}
|
||
return { Str: Buf.slice(Next, Next + Len).toString('utf8'), Next: Next + Len };
|
||
}
|
||
|
||
// ============================================================
|
||
// 单向流解码器:明文 → 探测密钥 → 实时解密
|
||
// ============================================================
|
||
|
||
class StreamDecoder {
|
||
constructor(State, Direction) {
|
||
this.State = State;
|
||
this.Direction = Direction;
|
||
this.Encoded = Direction === 'c2s'; // 上传方向密钥表再 ^87
|
||
this.Mode = 'plain';
|
||
this.Cipher = null;
|
||
this.Key = null;
|
||
this.Hint = null; // 对向方向破解出的密钥
|
||
this.Window = [];
|
||
}
|
||
|
||
Feed(Buf) {
|
||
if (this.Mode === 'cipher') {
|
||
this.Report(this.Cipher.Apply(Buf), true);
|
||
return;
|
||
}
|
||
if (this.Mode === 'plain') {
|
||
if (ParseFrames(Buf)) {
|
||
this.Report(Buf, false);
|
||
return;
|
||
}
|
||
// 明文解析失败 → 加密从这里开始
|
||
this.Mode = 'probing';
|
||
this.Window = [Buf];
|
||
this.State.Log('[' + this.Direction + '] 明文段结束,开始穷举密钥(首条密文 ' + Buf.length + ' 字节)');
|
||
return;
|
||
}
|
||
// probing:累积窗口,直到密钥唯一确定或放弃
|
||
// 先试对向密钥:命中即当场确定,省掉穷举等待
|
||
if (this.Hint !== null && StreamDecoder.SeriesOk(this.Window, this.Hint, this.Encoded)) {
|
||
this.State.Log('[' + this.Direction + '] 沿用对向密钥 K=' + this.Hint);
|
||
this.Key = this.Hint;
|
||
this.Cipher = new Cipher(this.Hint, this.Encoded);
|
||
this.Mode = 'cipher';
|
||
const Buffered = this.Window;
|
||
this.Window = [];
|
||
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
|
||
return;
|
||
}
|
||
this.Window.push(Buf);
|
||
const Bytes = this.Window.reduce((A, B) => A + B.length, 0);
|
||
const Hits = [];
|
||
for (let K = 0; K < 256; K++) {
|
||
if (StreamDecoder.SeriesOk(this.Window, K, this.Encoded)) Hits.push(K);
|
||
}
|
||
if (Hits.length === 1) {
|
||
const K = Hits[0];
|
||
this.State.Log('[' + this.Direction + '] 密钥已破解 K=' + K + '(窗口 ' + this.Window.length + ' 条 / ' + Bytes + ' 字节)');
|
||
this.Key = K;
|
||
this.Cipher = new Cipher(K, this.Encoded);
|
||
this.Mode = 'cipher';
|
||
const Buffered = this.Window;
|
||
this.Window = [];
|
||
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
|
||
return;
|
||
}
|
||
if (this.Window.length >= 8 || Bytes > 262144) {
|
||
this.State.Log('[' + this.Direction + '] 密钥探测失败(候选 ' + Hits.length + ' 个),后续按原始字节记录');
|
||
this.Mode = 'plain';
|
||
const Buffered = this.Window;
|
||
this.Window = [];
|
||
for (const B of Buffered) this.Report(B, false);
|
||
}
|
||
}
|
||
|
||
// 候选密钥:窗口内每条消息解密后都必须是完整帧
|
||
static SeriesOk(Window, K, Encoded) {
|
||
for (const Buf of Cipher.Series(Window, K, Encoded)) {
|
||
if (!ParseFrames(Buf)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
Report(Buf, Decrypted) {
|
||
const Tag = Decrypted ? '[密] ' : '[明] ';
|
||
const Frames = ParseFrames(Buf);
|
||
if (!Frames) {
|
||
this.State.Log(Tag + '非帧数据 ' + Buf.length + ' 字节: ' + Hex(Buf, 32));
|
||
return;
|
||
}
|
||
for (const Frame of Frames) {
|
||
let Body = Frame.Body;
|
||
let Note = Frame.Compressed ? ' [zlib…]' : '';
|
||
if (Frame.Compressed) {
|
||
try {
|
||
Body = zlib.inflateSync(Body);
|
||
Note = ' [zlib ' + Frame.Body.length + '→' + Body.length + ']';
|
||
} catch (Err) {
|
||
Note = ' [zlib 解压失败]';
|
||
}
|
||
}
|
||
const At = NullmapSize(Body, 0);
|
||
if (At >= Body.length) {
|
||
this.State.Log(Tag + '保活 ping (' + Buf.length + 'B)' + Note);
|
||
continue;
|
||
}
|
||
// 上行空间帧没有「命令 id」字节层:帧体直接是 SpaceCommand(objectId, methodId, 负载)
|
||
const Up = this.Direction === 'c2s' && ShowUplink ? DescribeUplink(Body, At) : null;
|
||
if (Up) {
|
||
this.State.Log(Tag + '空间命令帧 ' + (Body.length - At) + 'B' + Note);
|
||
for (const L of FormatUplink(Up, Body)) this.State.Log(L);
|
||
continue;
|
||
}
|
||
const Id = Body[At];
|
||
const Payload = Body.slice(At + 1, At + 41);
|
||
const Name = (CMD_NAME[this.Direction] && CMD_NAME[this.Direction][Id]) || '未知';
|
||
const Extra = this.Direction === 'c2s' && Id === 32 ? ' ' + StreamDecoder.DescribeClientLog(Body.slice(At + 1)) : '';
|
||
this.State.Log(
|
||
Tag + '命令 ID=' + String(Id).padStart(3) + ' (' + Name + ') 负载=' + (Body.length - At - 1) + 'B' + Note + Extra,
|
||
);
|
||
this.State.Log(' ' + Payload.toString('hex') + ' |' + Ascii(Payload) + '|');
|
||
}
|
||
}
|
||
|
||
// ClientLog body = [LogEntryType][string][string]
|
||
static DescribeClientLog(Buf) {
|
||
try {
|
||
const Type = Buf[0];
|
||
const S1 = ReadLogString(Buf, 1);
|
||
const S2 = ReadLogString(Buf, S1.Next);
|
||
return '类型=' + Type + ' ' + JSON.stringify(S1.Str) + ' ' + JSON.stringify(S2.Str);
|
||
} catch (Err) {
|
||
return '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 会话与落盘
|
||
// ============================================================
|
||
|
||
if (NeedDisk) fs.mkdirSync(PACKET_DIR, { recursive: true });
|
||
|
||
let SessionSeq = 0;
|
||
|
||
function NewSession() {
|
||
SessionSeq++;
|
||
let Dir = null;
|
||
let Name = 'session_' + String(SessionSeq).padStart(3, '0');
|
||
if (NeedDisk) {
|
||
// 只有需要落盘时才读/建目录(无谓的磁盘访问一律避免),并与已有抓包目录错开编号
|
||
let Used = [];
|
||
try {
|
||
Used = fs
|
||
.readdirSync(PACKET_DIR, { withFileTypes: true })
|
||
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
|
||
.map((D) => Number(D.name.slice('session_'.length)))
|
||
.filter((N) => !Number.isNaN(N));
|
||
} catch (Err) {}
|
||
Name = 'session_' + String((Used.length ? Math.max.apply(null, Used) : 0) + 1).padStart(3, '0');
|
||
Dir = path.join(PACKET_DIR, Name);
|
||
fs.mkdirSync(Dir, { recursive: true });
|
||
}
|
||
|
||
const State = { Dir, Name, Stream: null, C2s: 0, S2c: 0, Frames: 0 };
|
||
if (NeedDisk && WRITE_LOG) {
|
||
State.Stream = fs.createWriteStream(path.join(Dir, 'log.txt'), { flags: 'w' });
|
||
}
|
||
State.Log = (Msg) => {
|
||
const Line = '[' + new Date().toISOString() + '] ' + Msg;
|
||
if (!QUIET) console.log(Line);
|
||
if (State.Stream) State.Stream.write(Line + '\n');
|
||
};
|
||
return State;
|
||
}
|
||
|
||
// ===== 本地保存:单条**有序**异步写队列 =====
|
||
// 抓包/日志的落盘不参与转发路径:帧按接收顺序入队,队列内串行写盘(不并发、不阻塞事件循环),
|
||
// 因此落盘顺序 == 收发顺序,绝不错序;转发路径本身零等待零缓存。
|
||
let SaveQueue = [];
|
||
let SaveQueueBusy = false;
|
||
let SaveQueueBytes = 0;
|
||
const SAVE_QUEUE_MAX_BYTES = 64 * 1024 * 1024; // 积压上限,超过则丢弃最旧的落盘任务(只影响抓包文件)
|
||
|
||
function PumpSaveQueue() {
|
||
if (SaveQueueBusy) return;
|
||
const Task = SaveQueue.shift();
|
||
if (!Task) return;
|
||
SaveQueueBusy = true;
|
||
SaveQueueBytes -= Task.Bytes;
|
||
Task.Run(() => {
|
||
SaveQueueBusy = false;
|
||
PumpSaveQueue();
|
||
});
|
||
}
|
||
|
||
function EnqueueSave(Bytes, Run) {
|
||
if (SaveQueueBytes + Bytes > SAVE_QUEUE_MAX_BYTES) {
|
||
const Dropped = SaveQueue.shift();
|
||
if (Dropped) SaveQueueBytes -= Dropped.Bytes;
|
||
}
|
||
SaveQueue.push({ Bytes: Bytes, Run: Run });
|
||
SaveQueueBytes += Bytes;
|
||
PumpSaveQueue();
|
||
}
|
||
|
||
// 原始字节落盘(默认开启,每帧一个 .bin)+ 记录顺序(解析交给 StreamDecoder)
|
||
// 注意:写入走 EnqueueSave 的有序队列(异步、串行)——writeFileSync 会阻塞事件循环,
|
||
// 长对局里目录越来越大、写入越来越慢,转发随之变慢,表现为"游戏越玩越卡"。
|
||
// 要纯转发不落盘请加 --dump-every 0 --log 0。
|
||
function SaveFrame(State, Direction, Buf, Note) {
|
||
const Seq = Direction === 'c2s' ? State.C2s++ : State.S2c++;
|
||
State.Frames++;
|
||
const Tag = Direction + '_' + String(Seq).padStart(4, '0');
|
||
if (DUMP_EVERY > 0 && State.Dir && State.Frames % DUMP_EVERY === 0) {
|
||
const OutPath = path.join(State.Dir, Tag + '.bin');
|
||
EnqueueSave(Buf.length, (Done) => {
|
||
fs.writeFile(OutPath, Buf, (Err) => {
|
||
if (Err) console.log('[dump] 写入失败: ' + Err.message);
|
||
Done();
|
||
});
|
||
});
|
||
}
|
||
State.Log(
|
||
(Direction === 'c2s' ? '>>>' : '<<<') + ' ' + Tag + (State.Dir ? '.bin' : '') + ' ' +
|
||
Buf.length + ' 字节' + (Note ? ' ' + Note : ''),
|
||
);
|
||
if (WRITE_HEX && State.Stream) State.Stream.write(Buf.toString('hex') + '\n');
|
||
}
|
||
|
||
// ============================================================
|
||
// 服务端
|
||
// ============================================================
|
||
|
||
const Server = http.createServer((Req, Res) => {
|
||
Res.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' }).end('WebSocket only\n');
|
||
});
|
||
|
||
// 两个方向的 K 必然相同(一次构造展开两套密钥表),一侧破解后提示另一侧
|
||
function SyncKeys(Decoders) {
|
||
if (Decoders.s2c.Key !== null && Decoders.c2s.Hint === null) Decoders.c2s.Hint = Decoders.s2c.Key;
|
||
if (Decoders.c2s.Key !== null && Decoders.s2c.Hint === null) Decoders.s2c.Hint = Decoders.c2s.Key;
|
||
}
|
||
|
||
const WsServer = new Ws.Server({ server: Server });
|
||
|
||
WsServer.on('connection', (Client, Req) => {
|
||
const State = NewSession();
|
||
State.Log('=== 新客户端 ===');
|
||
State.Log('来源: ' + (Req.socket.remoteAddress || '?') + ' Origin: ' + (Req.headers.origin || '无'));
|
||
State.Log('模式: relay 上游: ' + UPSTREAM);
|
||
// 诊断:浏览器发给中继的握手头(判断中继有没有漏带握手层信息)
|
||
State.Log(
|
||
'客户端握手头: cookie=' + (Req.headers['cookie'] ? '有(' + Req.headers['cookie'].length + 'B)' : '无') +
|
||
' protocol=' + (Req.headers['sec-websocket-protocol'] || '无') +
|
||
' extensions=' + (Req.headers['sec-websocket-extensions'] || '无') +
|
||
' referer=' + (Req.headers['referer'] || '无'),
|
||
);
|
||
|
||
const Decoders = { c2s: new StreamDecoder(State, 'c2s'), s2c: new StreamDecoder(State, 's2c') };
|
||
let Upstream = null;
|
||
const Pending = []; // 上游未就绪时暂存竞态帧(握手帧常比上游 TLS 建连更早到达),一连上立即按原序补发
|
||
|
||
// 握手头:把客户端**真实**的握手请求头整包透传给上游(Cookie / Origin / User-Agent / Referer /
|
||
// Accept-Language / Sec-Fetch-* …),让上游看到的握手与「浏览器直连」完全一致。
|
||
// 仅排除必须由 ws 自己生成/协商的:Host / Connection / Upgrade / Sec-WebSocket-*
|
||
// (其中子协议改走 protocols 选项,由 ws 负责协商与校验)。
|
||
const UpHeaders = {};
|
||
for (const K of Object.keys(Req.headers)) {
|
||
const L = K.toLowerCase();
|
||
if (L === 'host' || L === 'connection' || L === 'upgrade') continue;
|
||
if (L.indexOf('sec-websocket-') === 0) continue;
|
||
UpHeaders[L] = Req.headers[K];
|
||
}
|
||
if (!UpHeaders['user-agent']) UpHeaders['user-agent'] = UPSTREAM_HEADERS['user-agent'];
|
||
// Origin/Referer **强制用真实站点的值**:前端(浏览器)带的是本机地址 http://localhost:8080,
|
||
// 真实服务器会据此校验来源,必须覆盖,不能透传。
|
||
UpHeaders['origin'] = UPSTREAM_HEADERS.origin;
|
||
UpHeaders['referer'] = UPSTREAM_HEADERS.referer;
|
||
// 诊断:打印**发往上游**的真实握手来源(注意与上面「来源: … Origin: …」那行区分开 ——
|
||
// DevTools 里看到的本机 Origin 是「浏览器→中继」这一段,页面 JS 改不了;中继改的是「中继→上游」这一段)
|
||
State.Log(
|
||
'上游握手头: origin=' + UpHeaders['origin'] +
|
||
' referer=' + UpHeaders['referer'] +
|
||
' user-agent=' + String(UpHeaders['user-agent'] || '').slice(0, 48) + '…' +
|
||
' cookie=' + (UpHeaders['cookie'] ? '有(' + UpHeaders['cookie'].length + 'B)' : '无'),
|
||
);
|
||
const ClientProto = Req.headers['sec-websocket-protocol'];
|
||
const UpProtocols = ClientProto
|
||
? String(ClientProto).split(',').map(function (S) { return S.trim(); }).filter(Boolean)
|
||
: undefined;
|
||
|
||
// 转发:客户端帧**原样直发**;上游未就绪时暂存,open 后按原序补发(不重放、不改字节)。
|
||
Upstream = new Ws(UPSTREAM, { headers: UpHeaders, protocols: UpProtocols, rejectUnauthorized: false });
|
||
Upstream.binaryType = 'nodebuffer';
|
||
Upstream.on('open', () => {
|
||
State.Log('上游已连接: ' + UPSTREAM + ',补发暂存 ' + Pending.length + ' 帧');
|
||
while (Pending.length) Upstream.send(Pending.shift());
|
||
});
|
||
Upstream.on('message', (Data) => {
|
||
const Buf = Buffer.from(Data);
|
||
SaveFrame(State, 's2c', Buf);
|
||
Decoders.s2c.Feed(Buf);
|
||
SyncKeys(Decoders);
|
||
if (Client.readyState === Ws.OPEN) Client.send(Buf);
|
||
});
|
||
Upstream.on('close', (Code) => {
|
||
State.Log('上游关闭 code=' + Code);
|
||
if (Client.readyState === Ws.OPEN) Client.close();
|
||
});
|
||
Upstream.on('error', (Err) => State.Log('上游错误: ' + Err.message));
|
||
|
||
Client.on('message', (Data) => {
|
||
const Buf = Buffer.from(Data);
|
||
SaveFrame(State, 'c2s', Buf);
|
||
Decoders.c2s.Feed(Buf);
|
||
SyncKeys(Decoders);
|
||
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.send(Buf);
|
||
else {
|
||
State.Log('上游尚未就绪,暂存 ' + Buf.length + ' 字节待补发');
|
||
Pending.push(Buf);
|
||
}
|
||
});
|
||
|
||
Client.on('close', (Code) => {
|
||
State.Log('客户端断开 code=' + Code + ' 收 ' + State.C2s + ' 条 / 发 ' + State.S2c + ' 条');
|
||
if (State.Stream) State.Stream.end(); // 不落盘时 Stream 为 null,必须判空
|
||
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.close();
|
||
});
|
||
Client.on('error', (Err) => State.Log('客户端错误: ' + Err.message));
|
||
});
|
||
|
||
Server.listen(PORT, () => {
|
||
console.log('游戏通信服务器已启动: ws://localhost:' + PORT + '/');
|
||
console.log(' 模式 : relay → ' + UPSTREAM);
|
||
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(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压');
|
||
});
|