@honeybbq/teamspeak-client 0.3.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +345 -64
  2. package/dist/abort.d.ts +5 -0
  3. package/dist/abort.d.ts.map +1 -0
  4. package/dist/address.d.ts +8 -0
  5. package/dist/address.d.ts.map +1 -1
  6. package/dist/api/voice.d.ts +24 -0
  7. package/dist/api/voice.d.ts.map +1 -0
  8. package/dist/capabilities.d.ts +62 -0
  9. package/dist/capabilities.d.ts.map +1 -0
  10. package/dist/client.d.ts +173 -39
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/commands.d.ts +45 -27
  13. package/dist/commands.d.ts.map +1 -1
  14. package/dist/crypto/crypt.d.ts +19 -4
  15. package/dist/crypto/crypt.d.ts.map +1 -1
  16. package/dist/crypto/identity.d.ts +87 -8
  17. package/dist/crypto/identity.d.ts.map +1 -1
  18. package/dist/crypto/index.d.ts +2 -1
  19. package/dist/crypto/index.d.ts.map +1 -1
  20. package/dist/diagnostics.d.ts +45 -0
  21. package/dist/diagnostics.d.ts.map +1 -0
  22. package/dist/discovery/resolver.d.ts +21 -3
  23. package/dist/discovery/resolver.d.ts.map +1 -1
  24. package/dist/errors.d.ts +70 -16
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/events.d.ts +4 -5
  27. package/dist/events.d.ts.map +1 -1
  28. package/dist/handshake/crypt-handshake.d.ts +1 -0
  29. package/dist/handshake/crypt-handshake.d.ts.map +1 -1
  30. package/dist/handshake/crypt-init2.d.ts.map +1 -1
  31. package/dist/handshake/license.d.ts +7 -0
  32. package/dist/handshake/license.d.ts.map +1 -1
  33. package/dist/handshake.d.ts +14 -5
  34. package/dist/handshake.d.ts.map +1 -1
  35. package/dist/helpers.d.ts +0 -8
  36. package/dist/helpers.d.ts.map +1 -1
  37. package/dist/index.d.ts +20 -6
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3996 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/notifications.d.ts +7 -15
  42. package/dist/notifications.d.ts.map +1 -1
  43. package/dist/reconnect.d.ts +69 -0
  44. package/dist/reconnect.d.ts.map +1 -0
  45. package/dist/throttle.d.ts +9 -0
  46. package/dist/throttle.d.ts.map +1 -1
  47. package/dist/transfer.d.ts +27 -25
  48. package/dist/transfer.d.ts.map +1 -1
  49. package/dist/transport/generation-window.d.ts +15 -0
  50. package/dist/transport/generation-window.d.ts.map +1 -1
  51. package/dist/transport/handler.d.ts +44 -5
  52. package/dist/transport/handler.d.ts.map +1 -1
  53. package/dist/transport/quicklz.d.ts +19 -0
  54. package/dist/transport/quicklz.d.ts.map +1 -1
  55. package/dist/types.d.ts +277 -97
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +21 -56
  58. package/src/abort.ts +24 -0
  59. package/src/address.ts +74 -0
  60. package/src/api/voice.ts +61 -0
  61. package/src/capabilities.ts +92 -0
  62. package/src/client.ts +1393 -0
  63. package/src/commands.ts +193 -0
  64. package/src/crypto/crypt-ops.ts +6 -0
  65. package/src/crypto/crypt.ts +274 -0
  66. package/src/crypto/eax.ts +152 -0
  67. package/src/crypto/identity.ts +484 -0
  68. package/src/crypto/index.ts +21 -0
  69. package/src/crypto/primitives.ts +141 -0
  70. package/src/diagnostics.ts +58 -0
  71. package/src/discovery/index.ts +1 -0
  72. package/src/discovery/resolver.ts +272 -0
  73. package/src/errors.ts +156 -0
  74. package/src/events.ts +28 -0
  75. package/src/handshake/crypt-handshake.ts +135 -0
  76. package/src/handshake/crypt-init2.ts +48 -0
  77. package/src/handshake/index.ts +3 -0
  78. package/src/handshake/license.fuzz.test.ts +87 -0
  79. package/src/handshake/license.test.ts +49 -0
  80. package/src/handshake/license.ts +208 -0
  81. package/src/handshake.ts +137 -0
  82. package/src/helpers.ts +10 -0
  83. package/src/index.ts +74 -0
  84. package/src/notifications.ts +136 -0
  85. package/src/reconnect.ts +141 -0
  86. package/src/throttle.ts +92 -0
  87. package/src/transfer.ts +127 -0
  88. package/src/transport/generation-window.ts +108 -0
  89. package/src/transport/handler.ts +917 -0
  90. package/src/transport/index.ts +14 -0
  91. package/src/transport/packet.ts +69 -0
  92. package/src/transport/quicklz.ts +209 -0
  93. package/src/types.ts +350 -0
  94. package/dist/address-BR-5unVo.cjs +0 -2
  95. package/dist/address-BR-5unVo.cjs.map +0 -1
  96. package/dist/address-BfmovVpp.js +0 -65
  97. package/dist/address-BfmovVpp.js.map +0 -1
  98. package/dist/api.d.ts +0 -17
  99. package/dist/api.d.ts.map +0 -1
  100. package/dist/command/command.d.ts +0 -12
  101. package/dist/command/command.d.ts.map +0 -1
  102. package/dist/command/index.cjs +0 -1
  103. package/dist/command/index.d.ts +0 -4
  104. package/dist/command/index.d.ts.map +0 -1
  105. package/dist/command/index.mjs +0 -3
  106. package/dist/command/parser.d.ts +0 -3
  107. package/dist/command/parser.d.ts.map +0 -1
  108. package/dist/command-BLe-C2L2.js +0 -74
  109. package/dist/command-BLe-C2L2.js.map +0 -1
  110. package/dist/command-DAK4pUBP.cjs +0 -4
  111. package/dist/command-DAK4pUBP.cjs.map +0 -1
  112. package/dist/crypt-handshake-CQkQreLx.cjs +0 -2
  113. package/dist/crypt-handshake-CQkQreLx.cjs.map +0 -1
  114. package/dist/crypt-handshake-DziOY4Lq.js +0 -55
  115. package/dist/crypt-handshake-DziOY4Lq.js.map +0 -1
  116. package/dist/crypt-init2-D70ha4LB.js +0 -158
  117. package/dist/crypt-init2-D70ha4LB.js.map +0 -1
  118. package/dist/crypt-init2-DparLekm.cjs +0 -2
  119. package/dist/crypt-init2-DparLekm.cjs.map +0 -1
  120. package/dist/crypto/index.cjs +0 -1
  121. package/dist/crypto/index.mjs +0 -3
  122. package/dist/crypto-Bg02m4ZE.js +0 -165
  123. package/dist/crypto-Bg02m4ZE.js.map +0 -1
  124. package/dist/crypto-CDlweUL3.cjs +0 -2
  125. package/dist/crypto-CDlweUL3.cjs.map +0 -1
  126. package/dist/discovery/index.cjs +0 -1
  127. package/dist/discovery/index.mjs +0 -2
  128. package/dist/handler-DI_abe9c.js +0 -483
  129. package/dist/handler-DI_abe9c.js.map +0 -1
  130. package/dist/handler-DuwoO5RC.cjs +0 -2
  131. package/dist/handler-DuwoO5RC.cjs.map +0 -1
  132. package/dist/handshake/index.cjs +0 -1
  133. package/dist/handshake/index.mjs +0 -3
  134. package/dist/index.cjs +0 -4
  135. package/dist/index.cjs.map +0 -1
  136. package/dist/index.mjs +0 -827
  137. package/dist/index.mjs.map +0 -1
  138. package/dist/parser-CL33R9tr.cjs +0 -2
  139. package/dist/parser-CL33R9tr.cjs.map +0 -1
  140. package/dist/parser-DZMv3Jm-.js +0 -33
  141. package/dist/parser-DZMv3Jm-.js.map +0 -1
  142. package/dist/primitives-cNNAeFCo.cjs +0 -2
  143. package/dist/primitives-cNNAeFCo.cjs.map +0 -1
  144. package/dist/primitives-xIr6Tjk4.js +0 -1457
  145. package/dist/primitives-xIr6Tjk4.js.map +0 -1
  146. package/dist/resolver-6B8dI9gr.js +0 -141
  147. package/dist/resolver-6B8dI9gr.js.map +0 -1
  148. package/dist/resolver-DCcmyWSK.cjs +0 -4
  149. package/dist/resolver-DCcmyWSK.cjs.map +0 -1
  150. package/dist/transport/index.cjs +0 -1
  151. package/dist/transport/index.mjs +0 -2
@@ -0,0 +1,917 @@
1
+ import { createSocket } from "node:dgram";
2
+ import { lookup } from "node:dns/promises";
3
+ import { parseConnectAddress } from "../address.js";
4
+ import type { Crypt } from "../crypto/crypt.js";
5
+ import type { DatagramSocket, SocketFactory } from "../types.js";
6
+ import type { Logger } from "@honeybbq/teamspeak-core";
7
+ import { noopLogger } from "@honeybbq/teamspeak-core";
8
+ import {
9
+ type Packet,
10
+ PacketType,
11
+ PacketFlags,
12
+ packetType,
13
+ packetFlags,
14
+ buildC2SHeader,
15
+ parseS2CHeader,
16
+ } from "./packet.js";
17
+ import { ConnectionTimeoutError } from "../errors.js";
18
+ import { abortable, abortReason } from "../abort.js";
19
+ import { NotConnectedError, TeamspeakError } from "@honeybbq/teamspeak-core";
20
+ import { GenerationWindow } from "./generation-window.js";
21
+ import { Qlz } from "./quicklz.js";
22
+ import { processInit1 } from "../handshake/crypt-handshake.js";
23
+
24
+ const MAX_OUT_PACKET_SIZE = 500;
25
+ const RECEIVE_PACKET_WINDOW_SIZE = 1024;
26
+ const PING_INTERVAL_MS = 5_000;
27
+ const PACKET_TIMEOUT_MS = 60_000;
28
+ const MAX_RETRY_INTERVAL_MS = 1_000;
29
+ const HEADER_SIZE = 5;
30
+ const TAG_SIZE = 8;
31
+ const VOICE_HEADER_SIZE = 3;
32
+ const WHISPER_HEADER_SIZE = 5;
33
+ const RESEND_BASE_INTERVAL_MS = 500;
34
+ const RESEND_LOOP_INTERVAL_MS = 100;
35
+
36
+ /**
37
+ * The largest command reassembled from fragments. The receive window already
38
+ * bounds how many fragments one command can span; this bounds their total
39
+ * size, which a datagram's own size limit would otherwise let reach tens of
40
+ * megabytes.
41
+ */
42
+ const MAX_REASSEMBLED_SIZE = 1 << 20;
43
+
44
+ /**
45
+ * The largest datagram taken. TeamSpeak's packets are at most 500 bytes; a
46
+ * larger one is no packet of the protocol's, and is dropped before anything
47
+ * is decrypted or held.
48
+ */
49
+ export const MAX_DATAGRAM_SIZE: number = 4096;
50
+
51
+ /**
52
+ * The most command bytes held, across both receive queues, waiting for the
53
+ * packets before them. A server's in-order traffic drains at once and a full
54
+ * window of real packets is under a megabyte; past this, a packet is dropped
55
+ * unacknowledged, so the server sends it again once there is room.
56
+ */
57
+ export const MAX_QUEUED_BYTES: number = 1 << 20;
58
+
59
+ /**
60
+ * The packet types the server may send unencrypted: voice, which TeamSpeak
61
+ * never encrypts, ping and pong, and acknowledgements — TS3 3.13 and TS6 6.0
62
+ * both acknowledge commands with plaintext Acks once crypto init has
63
+ * completed. An Ack carries nothing but the id it acknowledges, so a forged
64
+ * one can at worst stop a resend, which dropping the packet would do too.
65
+ * Init1 is handled before decryption.
66
+ */
67
+ const UNENCRYPTED_TYPES: ReadonlySet<number> = new Set<number>([
68
+ PacketType.Voice,
69
+ PacketType.VoiceWhisper,
70
+ PacketType.Ping,
71
+ PacketType.Pong,
72
+ PacketType.Ack,
73
+ PacketType.AckLow,
74
+ PacketType.Init1,
75
+ ]);
76
+
77
+ interface ResendPacket {
78
+ packet: Packet;
79
+ firstSend: number;
80
+ lastSend: number;
81
+ retryCount: number;
82
+ nextInterval: number;
83
+ }
84
+
85
+ export class PacketHandler {
86
+ onPacket: ((p: Packet) => void) | null = null;
87
+ onClosed: ((err: Error | null) => void) | null = null;
88
+ /**
89
+ * A received datagram could not be handled: it was malformed, or handling it
90
+ * threw. The datagram is dropped either way; the owner decides whether that
91
+ * ends the connection. Without an owner the error is logged.
92
+ */
93
+ onReceiveError: ((error: unknown) => void) | null = null;
94
+
95
+ readonly #crypt: Crypt;
96
+ readonly #logger: Logger;
97
+ readonly #socketFactory: SocketFactory | undefined;
98
+
99
+ #conn: DatagramSocket | null = null;
100
+ #clientID = 0;
101
+ #closed = false;
102
+ #stopPing: (() => void) | null = null;
103
+ #stopResend: (() => void) | null = null;
104
+ #lastMessageReceived = Date.now();
105
+
106
+ #packetCounter = new Uint16Array(9);
107
+ #generationCounter = new Uint32Array(9);
108
+
109
+ #recvWindowCommand = new GenerationWindow(1 << 16, RECEIVE_PACKET_WINDOW_SIZE);
110
+ #recvWindowCommandLow = new GenerationWindow(1 << 16, RECEIVE_PACKET_WINDOW_SIZE);
111
+ #sendWindowCommand = new GenerationWindow(1 << 16, RECEIVE_PACKET_WINDOW_SIZE);
112
+ #sendWindowCommandLow = new GenerationWindow(1 << 16, RECEIVE_PACKET_WINDOW_SIZE);
113
+
114
+ #commandQueue = new Map<number, Packet>();
115
+ #commandLowQueue = new Map<number, Packet>();
116
+ /** The bytes held in both command queues; see MAX_QUEUED_BYTES. */
117
+ #queuedBytes = 0;
118
+ #ackManager = new Map<number, ResendPacket>();
119
+ /**
120
+ * The last Init1 packet the client sent, resent until the handshake's final
121
+ * acknowledgement. Init1 packets are not acknowledged one by one: each step
122
+ * the client sends replaces it, and only `receivedFinalInitAck` clears it.
123
+ * Receiving an Init1 does not, as the client may have nothing to answer with.
124
+ */
125
+ #initPacketCheck: ResendPacket | null = null;
126
+ /**
127
+ * Command packets sent under the dummy key, before crypto init completed:
128
+ * clientek, in practice. Both TS3 and TS6 acknowledge clientek under the
129
+ * dummy key even though it completes crypto init, so the dummy key stays
130
+ * acceptable for exactly those acknowledgements, and for nothing else.
131
+ */
132
+ #dummyCommandIDs = new Set<number>();
133
+ #resends = 0;
134
+
135
+ constructor(crypt: Crypt, logger: Logger = noopLogger, socketFactory?: SocketFactory) {
136
+ this.#crypt = crypt;
137
+ this.#logger = logger;
138
+ this.#socketFactory = socketFactory;
139
+ }
140
+
141
+ setClientID(id: number): void {
142
+ this.#clientID = id;
143
+ }
144
+
145
+ /**
146
+ * Resolve `addr`, open a socket to it and start the handshake.
147
+ *
148
+ * Aborting `signal`, or closing the handler, while this runs abandons it: the
149
+ * lookup's answer is ignored, and a socket that finishes connecting
150
+ * afterwards is closed rather than started, so no handshake begins after
151
+ * the connection was given up.
152
+ */
153
+ async connect(addr: string, signal?: AbortSignal): Promise<void> {
154
+ const { host, portNumber } = parseConnectAddress(addr);
155
+ this.#throwIfAbandoned(signal);
156
+
157
+ // Resolve before choosing a socket family. `isIP()` only recognises literal
158
+ // addresses and returns 0 for every hostname, so picking the family from it
159
+ // sent every hostname to a udp4 socket — and a udp4 socket handed a name
160
+ // that resolves to IPv6 drops the datagrams with no error at all. On a
161
+ // stock macOS/Linux box `localhost` resolves to ::1 first, so that path
162
+ // silently never connected. Let DNS decide, then match the family.
163
+ let resolved: { address: string; family: number };
164
+ try {
165
+ resolved = await abortable(lookup(host), signal);
166
+ } catch (error) {
167
+ if (signal?.aborted) throw error;
168
+ // Wrapped, not classed as an invalid address: a lookup can fail for a
169
+ // while (EAI_AGAIN) and then succeed, which a reconnect should retry.
170
+ throw new TeamspeakError(`could not resolve ${host}`, { cause: error });
171
+ }
172
+ const { address, family } = resolved;
173
+ this.#throwIfAbandoned(signal);
174
+
175
+ const family6 = family === 6;
176
+ const socket: DatagramSocket = this.#socketFactory
177
+ ? this.#socketFactory(family6 ? 6 : 4)
178
+ : createSocket(family6 ? "udp6" : "udp4");
179
+
180
+ await new Promise<void>((resolve, reject) => {
181
+ let done = false;
182
+ const fail = (error: unknown): void => {
183
+ if (done) return;
184
+ done = true;
185
+ socket.off("error", onError);
186
+ signal?.removeEventListener("abort", onAbort);
187
+ closeQuietly(socket);
188
+ reject(error);
189
+ };
190
+ const onError = (error: Error): void => fail(error);
191
+ const onAbort = (): void => fail(abortReason(signal!));
192
+ socket.once("error", onError);
193
+ signal?.addEventListener("abort", onAbort, { once: true });
194
+ // Connect to the resolved address, not the original host, so the socket
195
+ // cannot end up talking to a different family than it was created for.
196
+ socket.connect(portNumber, address, () => {
197
+ if (done) return;
198
+ if (this.#closed || signal?.aborted) {
199
+ fail(signal?.aborted ? abortReason(signal) : new NotConnectedError());
200
+ return;
201
+ }
202
+ done = true;
203
+ socket.off("error", onError);
204
+ signal?.removeEventListener("abort", onAbort);
205
+ this.start(socket);
206
+ resolve();
207
+ });
208
+ });
209
+ }
210
+
211
+ #throwIfAbandoned(signal: AbortSignal | undefined): void {
212
+ signal?.throwIfAborted();
213
+ if (this.#closed) throw new NotConnectedError();
214
+ }
215
+
216
+ /** Take over a connected socket and start the handshake on it. A closed handler closes it instead. */
217
+ start(conn: DatagramSocket): void {
218
+ if (this.#closed) {
219
+ closeQuietly(conn);
220
+ return;
221
+ }
222
+ this.#conn = conn;
223
+ this.#lastMessageReceived = Date.now();
224
+
225
+ conn.on("message", (msg) => {
226
+ if (this.#closed) return;
227
+ // Everything here parses what the network sent. Nothing it throws may
228
+ // escape: from a socket callback it would be an uncaught exception, and
229
+ // a malformed datagram from the server would take the process down.
230
+ try {
231
+ this.#handleRawPacket(new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength));
232
+ } catch (error: unknown) {
233
+ if (this.onReceiveError) this.onReceiveError(error);
234
+ else this.#logger.debug("dropped a malformed packet", { error: String(error) });
235
+ }
236
+ });
237
+ conn.on("error", (err) => {
238
+ this.#logger.error("udp error", err);
239
+ this.#triggerClose(err);
240
+ });
241
+ conn.on("close", () => this.#triggerClose(null));
242
+
243
+ this.#packetCounter[PacketType.Command] = 1;
244
+ this.#packetCounter[PacketType.Init1] = 101;
245
+
246
+ const init1Data = processInit1(this.#crypt, null);
247
+ if (init1Data) this.#sendPacketRaw(PacketType.Init1, init1Data, 0);
248
+
249
+ // Ping loop
250
+ const pingTimer = setInterval(() => {
251
+ if (this.#crypt.cryptoInitComplete) {
252
+ this.sendPacket(PacketType.Ping, new Uint8Array(0), PacketFlags.Unencrypted);
253
+ }
254
+ }, PING_INTERVAL_MS);
255
+ this.#stopPing = () => clearInterval(pingTimer);
256
+
257
+ // Resend loop
258
+ const resendTimer = setInterval(() => this.#checkResends(), RESEND_LOOP_INTERVAL_MS);
259
+ this.#stopResend = () => clearInterval(resendTimer);
260
+ }
261
+
262
+ /**
263
+ * Stop resending the last Init1 packet the client sent. Until this is called,
264
+ * the latest step stays pending whatever the server sends in between: a step
265
+ * the client does not answer, an unknown one or a second RSA challenge, must
266
+ * not stop the resending of its own. The server's `initivexpand2` command
267
+ * acknowledges the last step, not an Init1 reply.
268
+ */
269
+ receivedFinalInitAck(): void {
270
+ this.#initPacketCheck = null;
271
+ }
272
+
273
+ sendPacket(pType: PacketType, data: Uint8Array, flags: number): void {
274
+ const dummy = !this.#crypt.cryptoInitComplete;
275
+ if (data.length > 487 && pType !== PacketType.Voice && pType !== PacketType.VoiceWhisper) {
276
+ this.#sendSplitPacket(pType, data, flags, dummy);
277
+ return;
278
+ }
279
+ this.#sendPacketRaw(pType, data, flags, dummy);
280
+ }
281
+
282
+ sendVoicePacket(data: Uint8Array, codec: number): void {
283
+ const pID = this.#packetCounter[PacketType.Voice]!;
284
+ const pGen = this.#generationCounter[PacketType.Voice]!;
285
+ this.#packetCounter[PacketType.Voice] = (pID + 1) & 0xffff;
286
+ if (this.#packetCounter[PacketType.Voice] === 0) {
287
+ this.#generationCounter[PacketType.Voice]!++;
288
+ }
289
+
290
+ const payloadLen = VOICE_HEADER_SIZE + data.length;
291
+ const voicePayload = new Uint8Array(payloadLen);
292
+ new DataView(voicePayload.buffer).setUint16(0, pID, false);
293
+ voicePayload[2] = codec;
294
+ voicePayload.set(data, VOICE_HEADER_SIZE);
295
+
296
+ const p: Packet = {
297
+ typeFlagged: PacketType.Voice | PacketFlags.Unencrypted,
298
+ id: pID,
299
+ clientID: this.#clientID,
300
+ generationID: pGen,
301
+ data: voicePayload,
302
+ receivedAt: 0,
303
+ };
304
+
305
+ const header = buildC2SHeader(p);
306
+ const final = new Uint8Array(TAG_SIZE + HEADER_SIZE + payloadLen);
307
+ final.set(this.#crypt.fakeSignature, 0);
308
+ final.set(header, TAG_SIZE);
309
+ final.set(voicePayload, TAG_SIZE + HEADER_SIZE);
310
+ this.#write(final);
311
+ }
312
+
313
+ /** Send an unencrypted TeamSpeak voice whisper packet to up to 32 clients. */
314
+ sendWhisperPacket(data: Uint8Array, targetClientIds: number[], codec: number): void {
315
+ if (targetClientIds.length === 0) throw new RangeError("a whisper needs at least one target");
316
+ if (targetClientIds.length > 32) throw new RangeError("a whisper has at most 32 targets");
317
+ if (targetClientIds.some((id) => !Number.isInteger(id) || id < 0 || id > 0xffff)) {
318
+ throw new RangeError("invalid whisper target");
319
+ }
320
+
321
+ const pID = this.#packetCounter[PacketType.VoiceWhisper]!;
322
+ const pGen = this.#generationCounter[PacketType.VoiceWhisper]!;
323
+ this.#packetCounter[PacketType.VoiceWhisper] = (pID + 1) & 0xffff;
324
+ if (this.#packetCounter[PacketType.VoiceWhisper] === 0) {
325
+ this.#generationCounter[PacketType.VoiceWhisper] = (pGen + 1) >>> 0;
326
+ }
327
+
328
+ const payloadLen = WHISPER_HEADER_SIZE + targetClientIds.length * 2 + data.length;
329
+ const whisperPayload = new Uint8Array(payloadLen);
330
+ const view = new DataView(whisperPayload.buffer);
331
+ view.setUint16(0, pID, false);
332
+ whisperPayload[2] = codec;
333
+ whisperPayload[3] = 0;
334
+ whisperPayload[4] = targetClientIds.length;
335
+ for (let index = 0; index < targetClientIds.length; index++) {
336
+ view.setUint16(WHISPER_HEADER_SIZE + index * 2, targetClientIds[index]!, false);
337
+ }
338
+ whisperPayload.set(data, WHISPER_HEADER_SIZE + targetClientIds.length * 2);
339
+
340
+ const packet: Packet = {
341
+ typeFlagged: PacketType.VoiceWhisper | PacketFlags.Unencrypted,
342
+ id: pID,
343
+ clientID: this.#clientID,
344
+ generationID: pGen,
345
+ data: whisperPayload,
346
+ receivedAt: 0,
347
+ };
348
+ const header = buildC2SHeader(packet);
349
+ const final = new Uint8Array(TAG_SIZE + HEADER_SIZE + payloadLen);
350
+ final.set(this.#crypt.fakeSignature, 0);
351
+ final.set(header, TAG_SIZE);
352
+ final.set(whisperPayload, TAG_SIZE + HEADER_SIZE);
353
+ this.#write(final);
354
+ }
355
+
356
+ /** How many packets have been sent again for want of an acknowledgement. */
357
+ get resends(): number {
358
+ return this.#resends;
359
+ }
360
+
361
+ /** Whether there is a live socket to send on. */
362
+ get isOpen(): boolean {
363
+ return this.#conn !== null && !this.#closed;
364
+ }
365
+
366
+ close(): void {
367
+ if (this.#closed) return;
368
+ this.#closed = true;
369
+ this.#stopPing?.();
370
+ this.#stopResend?.();
371
+
372
+ // The socket may already be closed — this runs from its own "close" event
373
+ // when a connection drops — and dgram throws ERR_SOCKET_DGRAM_NOT_RUNNING
374
+ // on a second close. Letting that escape would surface as an uncaught
375
+ // exception inside an event callback, taking the process with it instead of
376
+ // reporting a dropped connection.
377
+ const conn = this.#conn;
378
+ this.#conn = null;
379
+ if (conn === null) return;
380
+ try {
381
+ conn.close();
382
+ } catch (error: unknown) {
383
+ this.#logger.debug("socket already closed", { error: String(error) });
384
+ }
385
+ }
386
+
387
+ // ---- Private ---------------------------------------------------------------
388
+
389
+ #sendSplitPacket(pType: PacketType, data: Uint8Array, flags: number, dummy: boolean): void {
390
+ const maxSize = MAX_OUT_PACKET_SIZE - HEADER_SIZE - TAG_SIZE; // 487
391
+ let pos = 0;
392
+ let first = true;
393
+
394
+ while (pos < data.length) {
395
+ const blockSize = Math.min(data.length - pos, maxSize);
396
+ const last = pos + blockSize === data.length;
397
+
398
+ let pFlags = flags;
399
+ if (first !== last) pFlags |= PacketFlags.Fragmented;
400
+
401
+ this.#sendPacketRaw(pType, data.slice(pos, pos + blockSize), pFlags, dummy);
402
+ pos += blockSize;
403
+ first = false;
404
+ }
405
+ }
406
+
407
+ #sendPacketRaw(
408
+ pType: PacketType,
409
+ data: Uint8Array,
410
+ flags: number,
411
+ dummy = !this.#crypt.cryptoInitComplete,
412
+ ): void {
413
+ flags = applyProtocolFlags(pType, flags);
414
+ const [pID, pGen] = this.#nextPacketIdentity(pType);
415
+ const p: Packet = {
416
+ typeFlagged: pType | flags,
417
+ id: pID,
418
+ clientID: this.#clientID,
419
+ generationID: pGen,
420
+ data,
421
+ receivedAt: 0,
422
+ };
423
+
424
+ const unencrypted = (flags & PacketFlags.Unencrypted) !== 0;
425
+ const header = buildC2SHeader(p);
426
+ const [ciphertext, tag] = this.#crypt.encrypt(
427
+ pType,
428
+ pID,
429
+ pGen,
430
+ header,
431
+ data,
432
+ dummy,
433
+ unencrypted,
434
+ );
435
+
436
+ const final = new Uint8Array(TAG_SIZE + HEADER_SIZE + ciphertext.length);
437
+ final.set(tag.slice(0, TAG_SIZE), 0);
438
+ final.set(header, TAG_SIZE);
439
+ final.set(ciphertext, TAG_SIZE + HEADER_SIZE);
440
+ this.#write(final);
441
+ if (dummy && pType === PacketType.Command) this.#dummyCommandIDs.add(pID);
442
+
443
+ const rp: ResendPacket = {
444
+ packet: p,
445
+ firstSend: Date.now(),
446
+ lastSend: Date.now(),
447
+ retryCount: 0,
448
+ nextInterval: RESEND_BASE_INTERVAL_MS,
449
+ };
450
+ this.#trackResend(pType, p, rp);
451
+ }
452
+
453
+ #write(data: Uint8Array): void {
454
+ if (!this.#conn) return;
455
+ try {
456
+ this.#conn.send(data, (err) => {
457
+ if (err) this.#logger.warn("udp send error", err);
458
+ });
459
+ } catch (error: unknown) {
460
+ const normalized = error instanceof Error ? error : new Error(String(error));
461
+ // dgram can throw synchronously for an invalid family/address pair. A
462
+ // malformed remote target must close this session, never escape from an
463
+ // async socket callback and terminate the gateway process.
464
+ this.#logger.error("udp send failed", normalized);
465
+ this.#triggerClose(normalized);
466
+ }
467
+ }
468
+
469
+ #handleRawPacket(raw: Uint8Array): void {
470
+ if (raw.length < 11) return;
471
+ if (raw.length > MAX_DATAGRAM_SIZE) {
472
+ this.#logger.debug("dropped an oversized datagram", { size: raw.length });
473
+ return;
474
+ }
475
+
476
+ const tag = raw.slice(0, TAG_SIZE);
477
+ const header = raw.slice(TAG_SIZE, TAG_SIZE + 3);
478
+ const ciphertext = raw.slice(TAG_SIZE + 3);
479
+
480
+ const parsed = parseS2CHeader(header);
481
+ // Init1 belongs to the start of the handshake; once crypto init has
482
+ // completed, another one can only restart a session that is already set up.
483
+ if ((parsed.typeFlagged & 0x0f) === PacketType.Init1 && this.#crypt.cryptoInitComplete) return;
484
+
485
+ const p: Packet = {
486
+ ...parsed,
487
+ clientID: 0,
488
+ generationID: this.#resolveGeneration(parsed.id, parsed.typeFlagged & 0x0f),
489
+ data: new Uint8Array(0),
490
+ receivedAt: Date.now(),
491
+ };
492
+
493
+ const decrypted = this.#decryptPacketData(p, header, ciphertext, tag);
494
+ if (decrypted === null) return;
495
+ p.data = decrypted.plaintext;
496
+ // Only a packet that decrypted shows the server is there: anyone can send
497
+ // datagrams that do not, and they must not keep a dead session alive.
498
+ this.#lastMessageReceived = Date.now();
499
+
500
+ const pType = packetType(p);
501
+
502
+ if (pType === PacketType.Ping) {
503
+ this.#sendPong(p.id, decrypted.dummyUsed);
504
+ return;
505
+ }
506
+
507
+ if (!this.#handleCommandWindowAndAck(p, decrypted.dummyUsed)) return;
508
+
509
+ this.#handlePacketQueue(p);
510
+ this.#updatePostReceiveState(p);
511
+ }
512
+
513
+ #resolveGeneration(id: number, pType: number): number {
514
+ switch (pType as PacketType) {
515
+ case PacketType.Command:
516
+ return this.#recvWindowCommand.getGeneration(id);
517
+ case PacketType.CommandLow:
518
+ return this.#recvWindowCommandLow.getGeneration(id);
519
+ case PacketType.Ack:
520
+ return this.#sendWindowCommand.getGeneration(id);
521
+ case PacketType.AckLow:
522
+ return this.#sendWindowCommandLow.getGeneration(id);
523
+ default:
524
+ return 0;
525
+ }
526
+ }
527
+
528
+ #decryptPacketData(
529
+ p: Packet,
530
+ header: Uint8Array,
531
+ ciphertext: Uint8Array,
532
+ tag: Uint8Array,
533
+ ): { plaintext: Uint8Array; dummyUsed: boolean } | null {
534
+ const unencrypted = (packetFlags(p) & PacketFlags.Unencrypted) !== 0;
535
+ const dummy = !this.#crypt.cryptoInitComplete;
536
+ let dummyUsed = dummy;
537
+ const pType = packetType(p);
538
+ const gen = p.generationID;
539
+
540
+ // An unencrypted packet is checked against the fake signature only, and
541
+ // that signature is no secret: every plaintext ping, pong and voice packet
542
+ // carries it. A command must therefore never be taken unencrypted, or
543
+ // anyone on the path could forge text messages, kicks and command answers.
544
+ if (unencrypted && !UNENCRYPTED_TYPES.has(pType)) {
545
+ this.#logger.debug("dropped an unencrypted packet of a type that must be encrypted", {
546
+ type: pType,
547
+ id: p.id,
548
+ });
549
+ return null;
550
+ }
551
+
552
+ try {
553
+ const plaintext = this.#crypt.decrypt(
554
+ pType,
555
+ p.id,
556
+ gen,
557
+ header,
558
+ ciphertext,
559
+ tag,
560
+ dummy,
561
+ unencrypted,
562
+ );
563
+ return { plaintext, dummyUsed };
564
+ } catch {
565
+ // Before crypto init only the dummy key exists: there is no other key
566
+ // to try.
567
+ if (dummy) return null;
568
+ // No other generation is tried: the receive window already places the
569
+ // id in its generation, and a packet that decrypts only under the one
570
+ // before is an old packet replayed after the 16-bit id wrapped. Neither
571
+ // TS3 nor TS6 ever needed a guess at the next one.
572
+
573
+ // The acknowledgement of a command sent under the dummy key may itself
574
+ // use the dummy key, and is the one packet that may once crypto init has
575
+ // completed (see #dummyCommandIDs). A dummy-keyed command after that is
576
+ // dropped: the dummy key is public.
577
+ if (!dummy && pType === PacketType.Ack && !unencrypted) {
578
+ try {
579
+ const plaintext = this.#crypt.decrypt(
580
+ pType,
581
+ p.id,
582
+ gen,
583
+ header,
584
+ ciphertext,
585
+ tag,
586
+ true,
587
+ false,
588
+ );
589
+ const acked = plaintext.length >= 2 ? (plaintext[0]! << 8) | plaintext[1]! : Number.NaN;
590
+ if (this.#dummyCommandIDs.delete(acked)) return { plaintext, dummyUsed: true };
591
+ } catch {
592
+ // fall through
593
+ }
594
+ }
595
+
596
+ return null;
597
+ }
598
+ }
599
+
600
+ #handleCommandWindowAndAck(p: Packet, dummyUsed: boolean): boolean {
601
+ const pType = packetType(p);
602
+ if (pType !== PacketType.Command && pType !== PacketType.CommandLow) return true;
603
+
604
+ const win = pType === PacketType.Command ? this.#recvWindowCommand : this.#recvWindowCommandLow;
605
+ const ackType = pType === PacketType.Command ? PacketType.Ack : PacketType.AckLow;
606
+
607
+ if (!win.isInWindow(p.id)) {
608
+ if (win.isOldPacket(p.id)) {
609
+ this.#sendAck(p.id, ackType, dummyUsed);
610
+ }
611
+ return false;
612
+ }
613
+ const queue = pType === PacketType.Command ? this.#commandQueue : this.#commandLowQueue;
614
+ const held = queue.get(p.id)?.data.length ?? 0;
615
+ // The packet the queue waits for is always taken: it is what drains it.
616
+ const next = pType === PacketType.Command ? this.#_nextCommandID : this.#_nextCommandLowID;
617
+ if (p.id !== next && this.#queuedBytes - held + p.data.length > MAX_QUEUED_BYTES) {
618
+ // Not acknowledged: the server sends it again.
619
+ this.#logger.debug("dropped a command packet: the receive queues are full", { id: p.id });
620
+ return false;
621
+ }
622
+ this.#sendAck(p.id, ackType, dummyUsed);
623
+ return true;
624
+ }
625
+
626
+ #sendAck(packetID: number, ackType: PacketType, dummyUsed: boolean): void {
627
+ const ackData = new Uint8Array(2);
628
+ new DataView(ackData.buffer).setUint16(0, packetID, false);
629
+ this.#sendPacketRaw(ackType, ackData, 0, dummyUsed);
630
+ }
631
+
632
+ #sendPong(pID: number, dummyUsed: boolean): void {
633
+ const pongData = new Uint8Array(2);
634
+ new DataView(pongData.buffer).setUint16(0, pID, false);
635
+ this.#sendPacketRaw(PacketType.Pong, pongData, PacketFlags.Unencrypted, dummyUsed);
636
+ }
637
+
638
+ #handlePacketQueue(p: Packet): void {
639
+ const pType = packetType(p);
640
+ if (pType !== PacketType.Command && pType !== PacketType.CommandLow) {
641
+ this.onPacket?.(p);
642
+ return;
643
+ }
644
+
645
+ const isCommand = pType === PacketType.Command;
646
+ const queue = isCommand ? this.#commandQueue : this.#commandLowQueue;
647
+ const win = isCommand ? this.#recvWindowCommand : this.#recvWindowCommandLow;
648
+
649
+ this.#hold(queue, p);
650
+
651
+ if (isCommand) {
652
+ this.#fastForwardMissingPackets(queue, win, true);
653
+ } else {
654
+ this.#fastForwardMissingPackets(queue, win, false);
655
+ }
656
+
657
+ while (true) {
658
+ const current = isCommand ? this.#_nextCommandID : this.#_nextCommandLowID;
659
+ const packet = queue.get(current);
660
+ if (packet === undefined) break;
661
+
662
+ const result = this.#tryReassemble(packet, queue, current, win);
663
+ if (!result) break;
664
+
665
+ const [reassembled, newNext] = result;
666
+ if (isCommand) this.#_nextCommandID = newNext;
667
+ else this.#_nextCommandLowID = newNext;
668
+
669
+ if (reassembled === null) {
670
+ this.#logger.debug("dropped a command over the reassembly limit", { id: current });
671
+ continue;
672
+ }
673
+ if (!this.#tryDecompress(reassembled)) continue;
674
+ this.onPacket?.(reassembled);
675
+ }
676
+ }
677
+
678
+ /** Put a packet in its queue, counting its bytes; one with the same id is replaced. */
679
+ #hold(queue: Map<number, Packet>, p: Packet): void {
680
+ this.#release(queue, p.id);
681
+ queue.set(p.id, p);
682
+ this.#queuedBytes += p.data.length;
683
+ }
684
+
685
+ #release(queue: Map<number, Packet>, id: number): void {
686
+ const held = queue.get(id);
687
+ if (held === undefined) return;
688
+ queue.delete(id);
689
+ this.#queuedBytes -= held.data.length;
690
+ }
691
+
692
+ #_nextCommandID = 0;
693
+ #_nextCommandLowID = 0;
694
+
695
+ #fastForwardMissingPackets(
696
+ queue: Map<number, Packet>,
697
+ win: GenerationWindow,
698
+ isCommand: boolean,
699
+ ): void {
700
+ let nextID = isCommand ? this.#_nextCommandID : this.#_nextCommandLowID;
701
+ while (!queue.has(nextID) && hasOldNewerPacket(queue, nextID)) {
702
+ nextID = (nextID + 1) & 0xffff;
703
+ win.advance(1);
704
+ }
705
+ if (isCommand) this.#_nextCommandID = nextID;
706
+ else this.#_nextCommandLowID = nextID;
707
+ }
708
+
709
+ #tryReassemble(
710
+ startPacket: Packet,
711
+ queue: Map<number, Packet>,
712
+ nextID: number,
713
+ win: GenerationWindow,
714
+ ): [reassembled: Packet | null, newNextID: number] | null {
715
+ if ((packetFlags(startPacket) & PacketFlags.Fragmented) === 0) {
716
+ this.#release(queue, nextID);
717
+ win.advance(1);
718
+ return [startPacket, (nextID + 1) & 0xffff];
719
+ }
720
+
721
+ const fragments: Packet[] = [];
722
+ let totalSize = 0;
723
+ let currID = nextID;
724
+ let startSeen = false;
725
+
726
+ // Ends at the fragment that closes the command, or returns while one is
727
+ // still missing.
728
+ while (true) {
729
+ const frag = queue.get(currID);
730
+ if (!frag) return null;
731
+ fragments.push(frag);
732
+ totalSize += frag.data.length;
733
+ if (totalSize > MAX_REASSEMBLED_SIZE) {
734
+ // Dropped whole, so the window moves past it rather than waiting on
735
+ // the rest of a command that will never be accepted.
736
+ for (let i = 0; i < fragments.length; i++) {
737
+ this.#release(queue, nextID);
738
+ win.advance(1);
739
+ nextID = (nextID + 1) & 0xffff;
740
+ }
741
+ return [null, nextID];
742
+ }
743
+
744
+ const fragmented = (packetFlags(frag) & PacketFlags.Fragmented) !== 0;
745
+ if (!startSeen) {
746
+ startSeen = true;
747
+ if (!fragmented) break;
748
+ } else if (fragmented) {
749
+ break;
750
+ }
751
+ currID = (currID + 1) & 0xffff;
752
+ }
753
+
754
+ const combined = new Uint8Array(totalSize);
755
+ let pos = 0;
756
+ for (const frag of fragments) {
757
+ combined.set(frag.data, pos);
758
+ pos += frag.data.length;
759
+ this.#release(queue, nextID);
760
+ win.advance(1);
761
+ nextID = (nextID + 1) & 0xffff;
762
+ }
763
+
764
+ startPacket.data = combined;
765
+ startPacket.typeFlagged &= ~PacketFlags.Fragmented;
766
+ return [startPacket, nextID];
767
+ }
768
+
769
+ /** Decompress a command in place; false when it cannot be, and is to be dropped. */
770
+ #tryDecompress(packet: Packet): boolean {
771
+ if ((packetFlags(packet) & PacketFlags.Compressed) === 0) return true;
772
+ try {
773
+ const qlz = new Qlz();
774
+ packet.data = qlz.decompress(packet.data);
775
+ packet.typeFlagged &= ~PacketFlags.Compressed;
776
+ return true;
777
+ } catch (err) {
778
+ // Passing the compressed bytes on as if they were the command would
779
+ // hand the parser garbage.
780
+ this.#logger.debug("dropped a command that failed to decompress", {
781
+ id: packet.id,
782
+ error: String(err),
783
+ });
784
+ return false;
785
+ }
786
+ }
787
+
788
+ #updatePostReceiveState(p: Packet): void {
789
+ const pType = packetType(p);
790
+
791
+ if ((pType === PacketType.Ack || pType === PacketType.AckLow) && p.data.length >= 2) {
792
+ const ackID = new DataView(p.data.buffer, p.data.byteOffset).getUint16(0, false);
793
+ const targetType = pType === PacketType.Ack ? PacketType.Command : PacketType.CommandLow;
794
+ const key = (targetType << 16) | ackID;
795
+ this.#ackManager.delete(key);
796
+ }
797
+ }
798
+
799
+ #nextPacketIdentity(pType: PacketType): [id: number, gen: number] {
800
+ const pID = this.#packetCounter[pType]!;
801
+ const pGen = this.#generationCounter[pType]!;
802
+
803
+ if (pType === PacketType.Init1) return [pID, pGen];
804
+
805
+ this.#packetCounter[pType] = (pID + 1) & 0xffff;
806
+ if (this.#packetCounter[pType] === 0) {
807
+ this.#generationCounter[pType] = (pGen + 1) >>> 0;
808
+ }
809
+
810
+ if (pType === PacketType.Command) {
811
+ this.#sendWindowCommand.advanceToExcluded(pID);
812
+ } else if (pType === PacketType.CommandLow) {
813
+ this.#sendWindowCommandLow.advanceToExcluded(pID);
814
+ }
815
+
816
+ return [pID, pGen];
817
+ }
818
+
819
+ #trackResend(pType: PacketType, p: Packet, rp: ResendPacket): void {
820
+ if (pType === PacketType.Init1) {
821
+ this.#initPacketCheck = rp;
822
+ return;
823
+ }
824
+ if (pType === PacketType.Command || pType === PacketType.CommandLow) {
825
+ const key = (pType << 16) | p.id;
826
+ this.#ackManager.set(key, rp);
827
+ }
828
+ }
829
+
830
+ #checkResends(): void {
831
+ const now = Date.now();
832
+
833
+ if (now - this.#lastMessageReceived > PACKET_TIMEOUT_MS) {
834
+ this.#logger.warn("idle timeout");
835
+ this.#triggerClose(new ConnectionTimeoutError());
836
+ return;
837
+ }
838
+
839
+ if (this.#initPacketCheck) {
840
+ this.#doResend(this.#initPacketCheck, now);
841
+ }
842
+
843
+ for (const [key, rp] of this.#ackManager) {
844
+ if (now - rp.firstSend > PACKET_TIMEOUT_MS) {
845
+ this.#ackManager.delete(key);
846
+ this.#triggerClose(new ConnectionTimeoutError());
847
+ return;
848
+ }
849
+ this.#doResend(rp, now);
850
+ }
851
+ }
852
+
853
+ #doResend(rp: ResendPacket, now: number): void {
854
+ if (now - rp.lastSend < rp.nextInterval) return;
855
+
856
+ rp.lastSend = now;
857
+ rp.retryCount++;
858
+ this.#resends++;
859
+ rp.nextInterval = Math.min(rp.nextInterval * 2, MAX_RETRY_INTERVAL_MS);
860
+
861
+ const dummy = !this.#crypt.cryptoInitComplete;
862
+ const unencrypted = (packetFlags(rp.packet) & PacketFlags.Unencrypted) !== 0;
863
+ const header = buildC2SHeader(rp.packet);
864
+ const pType = packetType(rp.packet);
865
+
866
+ const [ciphertext, tag] = this.#crypt.encrypt(
867
+ pType,
868
+ rp.packet.id,
869
+ rp.packet.generationID,
870
+ header,
871
+ rp.packet.data,
872
+ dummy,
873
+ unencrypted,
874
+ );
875
+
876
+ const final = new Uint8Array(TAG_SIZE + HEADER_SIZE + ciphertext.length);
877
+ final.set(tag.slice(0, TAG_SIZE), 0);
878
+ final.set(header, TAG_SIZE);
879
+ final.set(ciphertext, TAG_SIZE + HEADER_SIZE);
880
+ this.#write(final);
881
+ }
882
+
883
+ #triggerClose(err: Error | null): void {
884
+ if (this.#closed) return;
885
+ this.close();
886
+ this.onClosed?.(err);
887
+ }
888
+ }
889
+
890
+ // ---- Helpers ----------------------------------------------------------------
891
+
892
+ /** Close a socket that may already be closed; dgram throws on a second close. */
893
+ function closeQuietly(socket: DatagramSocket): void {
894
+ try {
895
+ socket.close();
896
+ } catch {
897
+ // Already closed.
898
+ }
899
+ }
900
+
901
+ function applyProtocolFlags(pType: PacketType, flags: number): number {
902
+ if (pType === PacketType.Command || pType === PacketType.CommandLow) {
903
+ return flags | PacketFlags.NewProtocol;
904
+ }
905
+ return flags;
906
+ }
907
+
908
+ function hasOldNewerPacket(queue: Map<number, Packet>, nextID: number): boolean {
909
+ const now = Date.now();
910
+ for (const [id, pkg] of queue) {
911
+ const diff = (id - nextID + 0x10000) & 0xffff;
912
+ if (diff < 0x8000 && now - pkg.receivedAt > 5_000) {
913
+ return true;
914
+ }
915
+ }
916
+ return false;
917
+ }