Files
TankIC/3DTank/tools/wss_server.js
T
TermiNexus 8c7f87b58f fix(relay): 上游握手整包透传客户端请求头并镜像子协议,对齐浏览器直连
- 除 Host/Connection/Upgrade/Sec-WebSocket-* 外,客户端握手头全部原样带上(Cookie / Origin / User-Agent / Referer / Accept-Language / Sec-Fetch-* 等)

- Sec-WebSocket-Protocol 改走 ws 的 protocols 选项,由 ws 负责协商校验
2026-10-11 19:54:22 +08:00

581 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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://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();
// 上游握手头:真实服务器会校验来源
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/',
};
// 命令名(入口层已从源码确证;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);
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'];
if (!UpHeaders['origin']) UpHeaders['origin'] = UPSTREAM_HEADERS.origin;
if (!UpHeaders['referer']) UpHeaders['referer'] = UPSTREAM_HEADERS.referer;
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 压缩帧自动解压');
});