@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.
- package/README.md +345 -64
- package/dist/abort.d.ts +5 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/address.d.ts +8 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/api/voice.d.ts +24 -0
- package/dist/api/voice.d.ts.map +1 -0
- package/dist/capabilities.d.ts +62 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/client.d.ts +173 -39
- package/dist/client.d.ts.map +1 -1
- package/dist/commands.d.ts +45 -27
- package/dist/commands.d.ts.map +1 -1
- package/dist/crypto/crypt.d.ts +19 -4
- package/dist/crypto/crypt.d.ts.map +1 -1
- package/dist/crypto/identity.d.ts +87 -8
- package/dist/crypto/identity.d.ts.map +1 -1
- package/dist/crypto/index.d.ts +2 -1
- package/dist/crypto/index.d.ts.map +1 -1
- package/dist/diagnostics.d.ts +45 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/discovery/resolver.d.ts +21 -3
- package/dist/discovery/resolver.d.ts.map +1 -1
- package/dist/errors.d.ts +70 -16
- package/dist/errors.d.ts.map +1 -1
- package/dist/events.d.ts +4 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/handshake/crypt-handshake.d.ts +1 -0
- package/dist/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/handshake/crypt-init2.d.ts.map +1 -1
- package/dist/handshake/license.d.ts +7 -0
- package/dist/handshake/license.d.ts.map +1 -1
- package/dist/handshake.d.ts +14 -5
- package/dist/handshake.d.ts.map +1 -1
- package/dist/helpers.d.ts +0 -8
- package/dist/helpers.d.ts.map +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3996 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +7 -15
- package/dist/notifications.d.ts.map +1 -1
- package/dist/reconnect.d.ts +69 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/throttle.d.ts +9 -0
- package/dist/throttle.d.ts.map +1 -1
- package/dist/transfer.d.ts +27 -25
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transport/generation-window.d.ts +15 -0
- package/dist/transport/generation-window.d.ts.map +1 -1
- package/dist/transport/handler.d.ts +44 -5
- package/dist/transport/handler.d.ts.map +1 -1
- package/dist/transport/quicklz.d.ts +19 -0
- package/dist/transport/quicklz.d.ts.map +1 -1
- package/dist/types.d.ts +277 -97
- package/dist/types.d.ts.map +1 -1
- package/package.json +21 -56
- package/src/abort.ts +24 -0
- package/src/address.ts +74 -0
- package/src/api/voice.ts +61 -0
- package/src/capabilities.ts +92 -0
- package/src/client.ts +1393 -0
- package/src/commands.ts +193 -0
- package/src/crypto/crypt-ops.ts +6 -0
- package/src/crypto/crypt.ts +274 -0
- package/src/crypto/eax.ts +152 -0
- package/src/crypto/identity.ts +484 -0
- package/src/crypto/index.ts +21 -0
- package/src/crypto/primitives.ts +141 -0
- package/src/diagnostics.ts +58 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/resolver.ts +272 -0
- package/src/errors.ts +156 -0
- package/src/events.ts +28 -0
- package/src/handshake/crypt-handshake.ts +135 -0
- package/src/handshake/crypt-init2.ts +48 -0
- package/src/handshake/index.ts +3 -0
- package/src/handshake/license.fuzz.test.ts +87 -0
- package/src/handshake/license.test.ts +49 -0
- package/src/handshake/license.ts +208 -0
- package/src/handshake.ts +137 -0
- package/src/helpers.ts +10 -0
- package/src/index.ts +74 -0
- package/src/notifications.ts +136 -0
- package/src/reconnect.ts +141 -0
- package/src/throttle.ts +92 -0
- package/src/transfer.ts +127 -0
- package/src/transport/generation-window.ts +108 -0
- package/src/transport/handler.ts +917 -0
- package/src/transport/index.ts +14 -0
- package/src/transport/packet.ts +69 -0
- package/src/transport/quicklz.ts +209 -0
- package/src/types.ts +350 -0
- package/dist/address-BR-5unVo.cjs +0 -2
- package/dist/address-BR-5unVo.cjs.map +0 -1
- package/dist/address-BfmovVpp.js +0 -65
- package/dist/address-BfmovVpp.js.map +0 -1
- package/dist/api.d.ts +0 -17
- package/dist/api.d.ts.map +0 -1
- package/dist/command/command.d.ts +0 -12
- package/dist/command/command.d.ts.map +0 -1
- package/dist/command/index.cjs +0 -1
- package/dist/command/index.d.ts +0 -4
- package/dist/command/index.d.ts.map +0 -1
- package/dist/command/index.mjs +0 -3
- package/dist/command/parser.d.ts +0 -3
- package/dist/command/parser.d.ts.map +0 -1
- package/dist/command-BLe-C2L2.js +0 -74
- package/dist/command-BLe-C2L2.js.map +0 -1
- package/dist/command-DAK4pUBP.cjs +0 -4
- package/dist/command-DAK4pUBP.cjs.map +0 -1
- package/dist/crypt-handshake-CQkQreLx.cjs +0 -2
- package/dist/crypt-handshake-CQkQreLx.cjs.map +0 -1
- package/dist/crypt-handshake-DziOY4Lq.js +0 -55
- package/dist/crypt-handshake-DziOY4Lq.js.map +0 -1
- package/dist/crypt-init2-D70ha4LB.js +0 -158
- package/dist/crypt-init2-D70ha4LB.js.map +0 -1
- package/dist/crypt-init2-DparLekm.cjs +0 -2
- package/dist/crypt-init2-DparLekm.cjs.map +0 -1
- package/dist/crypto/index.cjs +0 -1
- package/dist/crypto/index.mjs +0 -3
- package/dist/crypto-Bg02m4ZE.js +0 -165
- package/dist/crypto-Bg02m4ZE.js.map +0 -1
- package/dist/crypto-CDlweUL3.cjs +0 -2
- package/dist/crypto-CDlweUL3.cjs.map +0 -1
- package/dist/discovery/index.cjs +0 -1
- package/dist/discovery/index.mjs +0 -2
- package/dist/handler-DI_abe9c.js +0 -483
- package/dist/handler-DI_abe9c.js.map +0 -1
- package/dist/handler-DuwoO5RC.cjs +0 -2
- package/dist/handler-DuwoO5RC.cjs.map +0 -1
- package/dist/handshake/index.cjs +0 -1
- package/dist/handshake/index.mjs +0 -3
- package/dist/index.cjs +0 -4
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs +0 -827
- package/dist/index.mjs.map +0 -1
- package/dist/parser-CL33R9tr.cjs +0 -2
- package/dist/parser-CL33R9tr.cjs.map +0 -1
- package/dist/parser-DZMv3Jm-.js +0 -33
- package/dist/parser-DZMv3Jm-.js.map +0 -1
- package/dist/primitives-cNNAeFCo.cjs +0 -2
- package/dist/primitives-cNNAeFCo.cjs.map +0 -1
- package/dist/primitives-xIr6Tjk4.js +0 -1457
- package/dist/primitives-xIr6Tjk4.js.map +0 -1
- package/dist/resolver-6B8dI9gr.js +0 -141
- package/dist/resolver-6B8dI9gr.js.map +0 -1
- package/dist/resolver-DCcmyWSK.cjs +0 -4
- package/dist/resolver-DCcmyWSK.cjs.map +0 -1
- package/dist/transport/index.cjs +0 -1
- 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
|
+
}
|