@honeybbq/teamspeak-client 0.3.1 → 1.0.0-beta.1
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 +322 -60
- 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 +172 -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 +3967 -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/handler.d.ts +37 -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 +1392 -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.test.ts +49 -0
- package/src/handshake/license.ts +208 -0
- package/src/handshake.ts +136 -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 +77 -0
- package/src/transport/handler.ts +914 -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 +349 -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
package/dist/index.js
ADDED
|
@@ -0,0 +1,3967 @@
|
|
|
1
|
+
import { CommandTimeoutError, ErrorCode, NotConnectedError, Reason, ServerError, TeamspeakError, asChannelID, asClientID, asUID, noopLogger } from "@honeybbq/teamspeak-core";
|
|
2
|
+
import { createCipheriv, createECDH, createHash, createPrivateKey, createPublicKey, createSign, createVerify, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
4
|
+
import { lookup, resolveSrv } from "node:dns/promises";
|
|
5
|
+
import { connect, createConnection, isIP } from "node:net";
|
|
6
|
+
import { createSocket } from "node:dgram";
|
|
7
|
+
import { buildCommand, buildCommandOrdered, encodeRequest, parseClient, parseCommand, parseErrorCode, parseIconID, parseInt10, parseServerError, parseUint16, parseUint64, responseSpec, sleep, splitCommandRows } from "@honeybbq/teamspeak-core/internal";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { channel, tracingChannel } from "node:diagnostics_channel";
|
|
10
|
+
export * from "@honeybbq/teamspeak-core";
|
|
11
|
+
/** Where a client is in its connection's life. */
|
|
12
|
+
var ClientStatus = /* @__PURE__ */ Object.freeze({
|
|
13
|
+
/** Not connected: before `connect()`, after `disconnect()`, or after a drop. */
|
|
14
|
+
Disconnected: "disconnected",
|
|
15
|
+
/** Connecting: `connect()` is waiting for the handshake. */
|
|
16
|
+
Connecting: "connecting",
|
|
17
|
+
/** Connected: the handshake completed and commands can be sent. */
|
|
18
|
+
Connected: "connected"
|
|
19
|
+
});
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/errors.ts
|
|
22
|
+
/** `connect()` was called on a client that is connecting or connected. */
|
|
23
|
+
var AlreadyConnectedError = class extends TeamspeakError {
|
|
24
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
25
|
+
static kind = "AlreadyConnectedError";
|
|
26
|
+
constructor() {
|
|
27
|
+
super("already connecting or connected");
|
|
28
|
+
this.name = "AlreadyConnectedError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The address a client was given cannot be connected to: no host, a host with
|
|
33
|
+
* characters no host name or IP address has, or a port outside 1–65535.
|
|
34
|
+
* `connect()` rejects with it before it starts, and the client stays
|
|
35
|
+
* disconnected. A name that does not resolve is not this: that fails with a
|
|
36
|
+
* TeamspeakError whose `cause` is the lookup's error, since DNS failures are
|
|
37
|
+
* often transient.
|
|
38
|
+
*/
|
|
39
|
+
var InvalidAddressError = class extends TeamspeakError {
|
|
40
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
41
|
+
static kind = "InvalidAddressError";
|
|
42
|
+
/** The address as given. */
|
|
43
|
+
address;
|
|
44
|
+
constructor(address, reason) {
|
|
45
|
+
super(`invalid address ${JSON.stringify(address)}: ${reason}`);
|
|
46
|
+
this.name = "InvalidAddressError";
|
|
47
|
+
this.address = address;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A packet failed authentication: it was corrupted, or not from the server.
|
|
52
|
+
* Internal: the packet is dropped, and this never reaches a caller.
|
|
53
|
+
*/
|
|
54
|
+
var EAXTagMismatchError = class extends TeamspeakError {
|
|
55
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
56
|
+
static kind = "EAXTagMismatchError";
|
|
57
|
+
constructor() {
|
|
58
|
+
super("EAX tag mismatch");
|
|
59
|
+
this.name = "EAXTagMismatchError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* An unencrypted packet failed its integrity check. Internal: the packet is
|
|
64
|
+
* dropped, and this never reaches a caller.
|
|
65
|
+
*/
|
|
66
|
+
var FakeSignatureMismatchError = class extends TeamspeakError {
|
|
67
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
68
|
+
static kind = "FakeSignatureMismatchError";
|
|
69
|
+
constructor() {
|
|
70
|
+
super("fake signature mismatch");
|
|
71
|
+
this.name = "FakeSignatureMismatchError";
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** A file transfer connection failed: it could not be opened, or broke during the transfer. */
|
|
75
|
+
var FileTransferError = class extends TeamspeakError {
|
|
76
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
77
|
+
static kind = "FileTransferError";
|
|
78
|
+
constructor(message, options) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.name = "FileTransferError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The handshake failed on what the server sent: a malformed or out-of-range
|
|
85
|
+
* Init1 step, public key, license or proof. `connect()` rejects with it, the
|
|
86
|
+
* underlying failure as its `cause`.
|
|
87
|
+
*/
|
|
88
|
+
var CryptoInitError = class extends TeamspeakError {
|
|
89
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
90
|
+
static kind = "CryptoInitError";
|
|
91
|
+
constructor(message, options) {
|
|
92
|
+
super(message, options);
|
|
93
|
+
this.name = "CryptoInitError";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** An identity string `parseIdentity()` cannot read. */
|
|
97
|
+
var InvalidIdentityError = class extends TeamspeakError {
|
|
98
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
99
|
+
static kind = "InvalidIdentityError";
|
|
100
|
+
constructor(message = "invalid identity format", options) {
|
|
101
|
+
super(message, options);
|
|
102
|
+
this.name = "InvalidIdentityError";
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* The server stopped answering: nothing arrived, or a packet went
|
|
107
|
+
* unacknowledged, for a minute.
|
|
108
|
+
*/
|
|
109
|
+
var ConnectionTimeoutError = class extends TeamspeakError {
|
|
110
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
111
|
+
static kind = "ConnectionTimeoutError";
|
|
112
|
+
constructor() {
|
|
113
|
+
super("connection timed out: the server stopped answering");
|
|
114
|
+
this.name = "ConnectionTimeoutError";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* The server ended this client's session: a kick, a ban, or the server
|
|
119
|
+
* stopping. The default reconnect policy retries a server that stopped, but
|
|
120
|
+
* not a kick or a ban.
|
|
121
|
+
*/
|
|
122
|
+
var RemovedError = class extends TeamspeakError {
|
|
123
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
124
|
+
static kind = "RemovedError";
|
|
125
|
+
/** Why: `Reason.KickServer`, `KickServerBan`, `ServerStop` or `ServerShutdown`. */
|
|
126
|
+
reason;
|
|
127
|
+
/** The reason the server or the invoker gave, if any. */
|
|
128
|
+
reasonMessage;
|
|
129
|
+
/** Who removed the client, for a kick or a ban. */
|
|
130
|
+
invokerName;
|
|
131
|
+
constructor(reason, reasonMessage, invokerName) {
|
|
132
|
+
let message = `removed from the server: ${reasonName(reason)}`;
|
|
133
|
+
if (invokerName) message += ` by ${invokerName}`;
|
|
134
|
+
if (reasonMessage) message += `: ${reasonMessage}`;
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "RemovedError";
|
|
137
|
+
this.reason = reason;
|
|
138
|
+
this.reasonMessage = reasonMessage;
|
|
139
|
+
this.invokerName = invokerName;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
function reasonName(reason) {
|
|
143
|
+
switch (reason) {
|
|
144
|
+
case Reason.KickServer: return "kicked from server";
|
|
145
|
+
case Reason.KickServerBan: return "banned from server";
|
|
146
|
+
case Reason.ServerStop: return "server stopped";
|
|
147
|
+
case Reason.ServerShutdown: return "server shut down";
|
|
148
|
+
default: return `reason ${reason}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/abort.ts
|
|
153
|
+
/** The reason an aborted signal gives, as an error. */
|
|
154
|
+
function abortReason(signal) {
|
|
155
|
+
return signal.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
156
|
+
}
|
|
157
|
+
/** Settle with `promise`, or reject as soon as `signal` aborts, whichever is first. */
|
|
158
|
+
function abortable(promise, signal) {
|
|
159
|
+
if (!signal) return promise;
|
|
160
|
+
if (signal.aborted) return Promise.reject(abortReason(signal));
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const onAbort = () => reject(abortReason(signal));
|
|
163
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
164
|
+
promise.then((value) => {
|
|
165
|
+
signal.removeEventListener("abort", onAbort);
|
|
166
|
+
resolve(value);
|
|
167
|
+
}, (error) => {
|
|
168
|
+
signal.removeEventListener("abort", onAbort);
|
|
169
|
+
reject(error);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/crypto/identity.ts
|
|
175
|
+
var P256_SCALAR_SIZE = 32;
|
|
176
|
+
var P256_UNCOMPRESSED_KEY_SIZE = 65;
|
|
177
|
+
var P256_POINT_PREFIX = 4;
|
|
178
|
+
/** The most a security level can be: SHA-1 is 160 bits wide. */
|
|
179
|
+
var MAX_SECURITY_LEVEL = 160;
|
|
180
|
+
var createIdentity;
|
|
181
|
+
var privateKeyOf;
|
|
182
|
+
/**
|
|
183
|
+
* A TeamSpeak client identity: a P-256 key pair plus a counter, the offset,
|
|
184
|
+
* whose hash with the public key sets the identity's security level.
|
|
185
|
+
*
|
|
186
|
+
* Immutable: raising the level returns a new identity. Opaque: the private
|
|
187
|
+
* key is never exposed, and `toString()`, `toJSON()` and `util.inspect()`
|
|
188
|
+
* describe the identity without it, so an identity that reaches a log or an
|
|
189
|
+
* error message does not leak; `export()` is the one way to get the secret
|
|
190
|
+
* form, for storing it. Make one with `generateIdentity()` or
|
|
191
|
+
* `parseIdentity()`.
|
|
192
|
+
*/
|
|
193
|
+
var Identity = class Identity {
|
|
194
|
+
static {
|
|
195
|
+
createIdentity = (privateKey, offset) => new Identity(privateKey, offset);
|
|
196
|
+
privateKeyOf = (identity) => identity.#privateKey;
|
|
197
|
+
}
|
|
198
|
+
#privateKey;
|
|
199
|
+
#offset;
|
|
200
|
+
#publicKey;
|
|
201
|
+
constructor(privateKey, offset) {
|
|
202
|
+
this.#privateKey = privateKey;
|
|
203
|
+
this.#offset = offset;
|
|
204
|
+
}
|
|
205
|
+
/** The counter whose hash with the public key sets the security level. */
|
|
206
|
+
get offset() {
|
|
207
|
+
return this.#offset;
|
|
208
|
+
}
|
|
209
|
+
/** The public key in TeamSpeak's encoding: base64 of its DER form. */
|
|
210
|
+
publicKey() {
|
|
211
|
+
if (this.#publicKey !== void 0) return this.#publicKey;
|
|
212
|
+
const rawJwk = this.#privateKey.export({ format: "jwk" });
|
|
213
|
+
if (typeof rawJwk.x !== "string" || typeof rawJwk.y !== "string") return "";
|
|
214
|
+
const xBytes = base64UrlToBytes$1(rawJwk.x, P256_SCALAR_SIZE);
|
|
215
|
+
const yBytes = base64UrlToBytes$1(rawJwk.y, P256_SCALAR_SIZE);
|
|
216
|
+
this.#publicKey = encodeTsPublicKey(xBytes, yBytes);
|
|
217
|
+
return this.#publicKey;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The identity's unique identifier, hashed with `algorithm`: SHA-1 for a
|
|
221
|
+
* TeamSpeak 3 server, SHA-256 for TeamSpeak 6. A connected client's `uid`
|
|
222
|
+
* already uses the one its server does.
|
|
223
|
+
*/
|
|
224
|
+
uid(algorithm) {
|
|
225
|
+
return uidFromPublicKey(this.publicKey(), algorithm);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The identity in the form `parseIdentity()` reads, private key included.
|
|
229
|
+
* Store it as you would a password.
|
|
230
|
+
*/
|
|
231
|
+
export() {
|
|
232
|
+
const jwk = this.#privateKey.export({ format: "jwk" });
|
|
233
|
+
if (!jwk.d) return `:${this.#offset}`;
|
|
234
|
+
const dBytes = base64UrlToBytes$1(jwk.d, P256_SCALAR_SIZE);
|
|
235
|
+
return `${Buffer.from(dBytes).toString("base64")}:${this.#offset}`;
|
|
236
|
+
}
|
|
237
|
+
/** Describes the identity, by its SHA-1 unique identifier, without its private key. */
|
|
238
|
+
toString() {
|
|
239
|
+
return `identity ${this.uid("sha1")} (private key redacted)`;
|
|
240
|
+
}
|
|
241
|
+
/** The same as `toString()`, so serialising an identity cannot leak its key. */
|
|
242
|
+
toJSON() {
|
|
243
|
+
return this.toString();
|
|
244
|
+
}
|
|
245
|
+
/** The security level: trailing zero bits in SHA-1 of the public key and offset. */
|
|
246
|
+
securityLevel() {
|
|
247
|
+
return levelAt(this.publicKey(), this.#offset);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The same key at an offset whose security level reaches `targetLevel`, as
|
|
251
|
+
* a new identity; this one is unchanged, and returned as it is when it
|
|
252
|
+
* already reaches the level.
|
|
253
|
+
*
|
|
254
|
+
* Each level doubles the expected work, so a high level can take a long
|
|
255
|
+
* time; `signal` bounds it, and the event loop keeps running meanwhile. A
|
|
256
|
+
* level above 160, the width of SHA-1, can never be reached and is refused
|
|
257
|
+
* with a RangeError.
|
|
258
|
+
*/
|
|
259
|
+
async upgradeToLevel(targetLevel, options = {}) {
|
|
260
|
+
checkLevel(targetLevel);
|
|
261
|
+
const { signal } = options;
|
|
262
|
+
const prefix = this.publicKey();
|
|
263
|
+
let offset = this.#offset;
|
|
264
|
+
while (true) {
|
|
265
|
+
signal?.throwIfAborted();
|
|
266
|
+
if (levelAt(prefix, offset) >= targetLevel) return offset === this.#offset ? this : new Identity(this.#privateKey, offset);
|
|
267
|
+
offset++;
|
|
268
|
+
if (offset % 10000n === 0n) await new Promise((resolve) => setImmediate(resolve));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
Object.defineProperty(Identity.prototype, Symbol.for("nodejs.util.inspect.custom"), { value() {
|
|
273
|
+
return `Identity <${this.toString()}>`;
|
|
274
|
+
} });
|
|
275
|
+
/**
|
|
276
|
+
* The private key of an identity, for the handshake. Not exported from the
|
|
277
|
+
* package.
|
|
278
|
+
*
|
|
279
|
+
* @internal
|
|
280
|
+
*/
|
|
281
|
+
function identityPrivateKey(identity) {
|
|
282
|
+
return privateKeyOf(identity);
|
|
283
|
+
}
|
|
284
|
+
function checkLevel(level) {
|
|
285
|
+
if (!Number.isInteger(level) || level < 0 || level > MAX_SECURITY_LEVEL) throw new RangeError(`a security level is an integer from 0 to 160, not ${level}`);
|
|
286
|
+
}
|
|
287
|
+
function levelAt(publicKey, offset) {
|
|
288
|
+
const h = createHash("sha1");
|
|
289
|
+
h.update(publicKey);
|
|
290
|
+
h.update(offset.toString(10));
|
|
291
|
+
return countLeadingZeros(h.digest());
|
|
292
|
+
}
|
|
293
|
+
/** The largest offset, an unsigned 64-bit counter. */
|
|
294
|
+
var MAX_UINT64 = (1n << 64n) - 1n;
|
|
295
|
+
/**
|
|
296
|
+
* Read an identity from the form `Identity.export()` writes: the private key
|
|
297
|
+
* as canonical base64 of at most 32 bytes, a colon, and the offset as a
|
|
298
|
+
* decimal unsigned 64-bit integer. Anything else throws an
|
|
299
|
+
* InvalidIdentityError, with the underlying failure as its cause.
|
|
300
|
+
*/
|
|
301
|
+
function parseIdentity(s) {
|
|
302
|
+
const colonIdx = s.lastIndexOf(":");
|
|
303
|
+
if (colonIdx < 0) throw new InvalidIdentityError();
|
|
304
|
+
const dBase64 = s.slice(0, colonIdx);
|
|
305
|
+
const offsetStr = s.slice(colonIdx + 1);
|
|
306
|
+
if (!/^\d+$/.test(offsetStr) || BigInt(offsetStr) > MAX_UINT64) throw new InvalidIdentityError("invalid identity offset");
|
|
307
|
+
const offset = BigInt(offsetStr);
|
|
308
|
+
const dBytes = Buffer.from(dBase64, "base64");
|
|
309
|
+
if (dBytes.length === 0 || dBytes.toString("base64") !== dBase64) throw new InvalidIdentityError("the private key is not canonical base64");
|
|
310
|
+
if (dBytes.length > P256_SCALAR_SIZE) throw new InvalidIdentityError("private key scalar too large");
|
|
311
|
+
const padded = Buffer.alloc(P256_SCALAR_SIZE);
|
|
312
|
+
dBytes.copy(padded, P256_SCALAR_SIZE - dBytes.length);
|
|
313
|
+
try {
|
|
314
|
+
const ecdh = createECDH("prime256v1");
|
|
315
|
+
ecdh.setPrivateKey(padded);
|
|
316
|
+
const pub = ecdh.getPublicKey(null, "uncompressed");
|
|
317
|
+
const x = Buffer.from(pub.slice(1, 33)).toString("base64url");
|
|
318
|
+
const y = Buffer.from(pub.slice(33, 65)).toString("base64url");
|
|
319
|
+
const jwk = {
|
|
320
|
+
kty: "EC",
|
|
321
|
+
crv: "P-256",
|
|
322
|
+
d: padded.toString("base64url"),
|
|
323
|
+
x,
|
|
324
|
+
y
|
|
325
|
+
};
|
|
326
|
+
return createIdentity(createPrivateKey({
|
|
327
|
+
key: jwk,
|
|
328
|
+
format: "jwk"
|
|
329
|
+
}), offset);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw new InvalidIdentityError("invalid private key", { cause: error });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Create an identity at the given security level, at most 160. Each level
|
|
336
|
+
* doubles the expected work; `signal` bounds it, and the event loop keeps
|
|
337
|
+
* running meanwhile.
|
|
338
|
+
*/
|
|
339
|
+
async function generateIdentity(targetLevel, options = {}) {
|
|
340
|
+
checkLevel(targetLevel);
|
|
341
|
+
return createIdentity(generateKey(), 0n).upgradeToLevel(targetLevel, options);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Create an identity at the given security level, at most 160, blocking until
|
|
345
|
+
* done. Fine for a low level; a high one can block the event loop for minutes,
|
|
346
|
+
* so prefer `generateIdentity()` there.
|
|
347
|
+
*/
|
|
348
|
+
function generateIdentitySync(targetLevel) {
|
|
349
|
+
checkLevel(targetLevel);
|
|
350
|
+
const key = generateKey();
|
|
351
|
+
const prefix = createIdentity(key, 0n).publicKey();
|
|
352
|
+
let offset = 0n;
|
|
353
|
+
while (levelAt(prefix, offset) < targetLevel) offset++;
|
|
354
|
+
return createIdentity(key, offset);
|
|
355
|
+
}
|
|
356
|
+
function generateKey() {
|
|
357
|
+
return generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey;
|
|
358
|
+
}
|
|
359
|
+
/** Pick the UID hash from a `virtualserver_version` string, e.g. "6.0.0-beta13.1". */
|
|
360
|
+
function uidAlgorithmForVersion(version) {
|
|
361
|
+
const major = Number.parseInt(version, 10);
|
|
362
|
+
return Number.isNaN(major) || major < 6 ? "sha1" : "sha256";
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The unique identifier of a public key in TeamSpeak's encoding, hashed with
|
|
366
|
+
* `algorithm`: SHA-1 for a TeamSpeak 3 server, SHA-256 for TeamSpeak 6.
|
|
367
|
+
*/
|
|
368
|
+
function uidFromPublicKey(publicKey, algorithm) {
|
|
369
|
+
return asUID(createHash(algorithm).update(publicKey).digest("base64"));
|
|
370
|
+
}
|
|
371
|
+
function hash512(data) {
|
|
372
|
+
return createHash("sha512").update(data).digest();
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Encode a P-256 public key as the TS3 canonical ASN.1 SEQUENCE and return
|
|
376
|
+
* its base64 representation.
|
|
377
|
+
*
|
|
378
|
+
* TS3 canonical format (v3+):
|
|
379
|
+
* SEQUENCE {
|
|
380
|
+
* BIT STRING { unused=7, value=0x00 } — DER: 03 02 07 00
|
|
381
|
+
* INTEGER 32
|
|
382
|
+
* INTEGER x
|
|
383
|
+
* INTEGER y
|
|
384
|
+
* }
|
|
385
|
+
*/
|
|
386
|
+
function encodeTsPublicKey(x, y) {
|
|
387
|
+
const bitStringDer = encodeDerTlv(3, Buffer.from([7, 0]));
|
|
388
|
+
const integerSize = encodeDerTlv(2, encodePositiveInt(32));
|
|
389
|
+
const integerX = encodeDerTlv(2, encodePositiveInt(x));
|
|
390
|
+
const integerY = encodeDerTlv(2, encodePositiveInt(y));
|
|
391
|
+
return encodeDerTlv(48, Buffer.concat([
|
|
392
|
+
bitStringDer,
|
|
393
|
+
integerSize,
|
|
394
|
+
integerX,
|
|
395
|
+
integerY
|
|
396
|
+
])).toString("base64");
|
|
397
|
+
}
|
|
398
|
+
/** Import a TS3 public key (canonical or legacy ASN.1 DER) and return the
|
|
399
|
+
* uncompressed 65-byte point [0x04 || x || y]. */
|
|
400
|
+
function importPublicKey(data) {
|
|
401
|
+
const point = parseTsPublicKeyDer(data);
|
|
402
|
+
const jwk = {
|
|
403
|
+
kty: "EC",
|
|
404
|
+
crv: "P-256",
|
|
405
|
+
x: Buffer.from(point.slice(1, 33)).toString("base64url"),
|
|
406
|
+
y: Buffer.from(point.slice(33, 65)).toString("base64url")
|
|
407
|
+
};
|
|
408
|
+
return createPublicKey({
|
|
409
|
+
key: jwk,
|
|
410
|
+
format: "jwk"
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Parse both TS3 canonical and legacy ASN.1 formats and return the
|
|
415
|
+
* uncompressed 65-byte point [0x04 || x || y].
|
|
416
|
+
*/
|
|
417
|
+
function parseTsPublicKeyDer(data) {
|
|
418
|
+
try {
|
|
419
|
+
return parseTsCanonicalPublicKey(data);
|
|
420
|
+
} catch {}
|
|
421
|
+
return parseTsLegacyPublicKey(data);
|
|
422
|
+
}
|
|
423
|
+
function parseTsCanonicalPublicKey(data) {
|
|
424
|
+
const seq = parseDerTlv(data, 48).value;
|
|
425
|
+
let offset = 0;
|
|
426
|
+
const bs = parseDerTlv(seq, 3, offset);
|
|
427
|
+
offset += bs.consumed;
|
|
428
|
+
const sizeInt = parseDerTlv(seq, 2, offset);
|
|
429
|
+
offset += sizeInt.consumed;
|
|
430
|
+
const xInt = parseDerTlv(seq, 2, offset);
|
|
431
|
+
offset += xInt.consumed;
|
|
432
|
+
const yInt = parseDerTlv(seq, 2, offset);
|
|
433
|
+
return buildUncompressedPoint(positiveIntToBytes(xInt.value, P256_SCALAR_SIZE), positiveIntToBytes(yInt.value, P256_SCALAR_SIZE));
|
|
434
|
+
}
|
|
435
|
+
function parseTsLegacyPublicKey(data) {
|
|
436
|
+
const seq = parseDerTlv(data, 48).value;
|
|
437
|
+
let offset = 0;
|
|
438
|
+
const xInt = parseDerTlv(seq, 2, offset);
|
|
439
|
+
offset += xInt.consumed;
|
|
440
|
+
const yInt = parseDerTlv(seq, 2, offset);
|
|
441
|
+
return buildUncompressedPoint(positiveIntToBytes(xInt.value, P256_SCALAR_SIZE), positiveIntToBytes(yInt.value, P256_SCALAR_SIZE));
|
|
442
|
+
}
|
|
443
|
+
function buildUncompressedPoint(x, y) {
|
|
444
|
+
if (x.length > P256_SCALAR_SIZE || y.length > P256_SCALAR_SIZE) throw new Error("invalid public key point encoding");
|
|
445
|
+
const point = new Uint8Array(P256_UNCOMPRESSED_KEY_SIZE);
|
|
446
|
+
point[0] = P256_POINT_PREFIX;
|
|
447
|
+
point.set(x, 33 - x.length);
|
|
448
|
+
point.set(y, 65 - y.length);
|
|
449
|
+
return point;
|
|
450
|
+
}
|
|
451
|
+
function encodeDerTlv(tag, value) {
|
|
452
|
+
const lenBytes = encodeDerLength(value.length);
|
|
453
|
+
return Buffer.concat([
|
|
454
|
+
Buffer.from([tag]),
|
|
455
|
+
lenBytes,
|
|
456
|
+
value
|
|
457
|
+
]);
|
|
458
|
+
}
|
|
459
|
+
function encodeDerLength(len) {
|
|
460
|
+
if (len < 128) return Buffer.from([len]);
|
|
461
|
+
if (len < 256) return Buffer.from([129, len]);
|
|
462
|
+
return Buffer.from([
|
|
463
|
+
130,
|
|
464
|
+
len >> 8 & 255,
|
|
465
|
+
len & 255
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
/** Encode a non-negative value as a DER INTEGER (with 0x00 prefix if high bit set). */
|
|
469
|
+
function encodePositiveInt(v) {
|
|
470
|
+
if (typeof v === "number") {
|
|
471
|
+
const bytes = [];
|
|
472
|
+
let n = v;
|
|
473
|
+
do {
|
|
474
|
+
bytes.unshift(n & 255);
|
|
475
|
+
n >>= 8;
|
|
476
|
+
} while (n > 0);
|
|
477
|
+
if ((bytes[0] & 128) !== 0) bytes.unshift(0);
|
|
478
|
+
return Buffer.from(bytes);
|
|
479
|
+
}
|
|
480
|
+
let start = 0;
|
|
481
|
+
while (start < v.length - 1 && v[start] === 0) start++;
|
|
482
|
+
const trimmed = v.slice(start);
|
|
483
|
+
if ((trimmed[0] & 128) !== 0) return Buffer.concat([Buffer.from([0]), trimmed]);
|
|
484
|
+
return Buffer.from(trimmed);
|
|
485
|
+
}
|
|
486
|
+
function parseDerTlv(data, expectedTag, offset = 0) {
|
|
487
|
+
if (data[offset] !== expectedTag) throw new Error(`expected DER tag 0x${expectedTag.toString(16)}, got 0x${(data[offset] ?? 0).toString(16)}`);
|
|
488
|
+
let pos = offset + 1;
|
|
489
|
+
let len;
|
|
490
|
+
const firstLenByte = data[pos++];
|
|
491
|
+
if (firstLenByte < 128) len = firstLenByte;
|
|
492
|
+
else if (firstLenByte === 129) len = data[pos++];
|
|
493
|
+
else if (firstLenByte === 130) {
|
|
494
|
+
len = (data[pos] << 8 | data[pos + 1]) >>> 0;
|
|
495
|
+
pos += 2;
|
|
496
|
+
} else throw new Error("unsupported DER length encoding");
|
|
497
|
+
return {
|
|
498
|
+
value: data.slice(pos, pos + len),
|
|
499
|
+
consumed: pos - offset + len
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** Convert a DER INTEGER value (may have leading 0x00) to fixed-length bytes. */
|
|
503
|
+
function positiveIntToBytes(int, size) {
|
|
504
|
+
let start = 0;
|
|
505
|
+
while (start < int.length - 1 && int[start] === 0) start++;
|
|
506
|
+
const stripped = int.slice(start);
|
|
507
|
+
if (stripped.length > size) throw new Error("integer too large");
|
|
508
|
+
if (stripped.length === size) return stripped;
|
|
509
|
+
const padded = new Uint8Array(size);
|
|
510
|
+
padded.set(stripped, size - stripped.length);
|
|
511
|
+
return padded;
|
|
512
|
+
}
|
|
513
|
+
function base64UrlToBytes$1(b64url, size) {
|
|
514
|
+
const buf = Buffer.from(b64url, "base64url");
|
|
515
|
+
if (buf.length === size) return buf;
|
|
516
|
+
const padded = Buffer.alloc(size);
|
|
517
|
+
buf.copy(padded, size - buf.length);
|
|
518
|
+
return padded;
|
|
519
|
+
}
|
|
520
|
+
function countLeadingZeros(data) {
|
|
521
|
+
let zeros = 0;
|
|
522
|
+
for (const b of data) if (b === 0) zeros += 8;
|
|
523
|
+
else for (let i = 0; i < 8; i++) if ((b & 1 << i) === 0) zeros++;
|
|
524
|
+
else return zeros;
|
|
525
|
+
return zeros;
|
|
526
|
+
}
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/crypto/eax.ts
|
|
529
|
+
var EAX_TAG_SIZE = 8;
|
|
530
|
+
var EAX_BLOCK_SIZE = 16;
|
|
531
|
+
/**
|
|
532
|
+
* AES-EAX AEAD implementation for TS3 (64-bit tag, AES-128).
|
|
533
|
+
* Uses AES-CMAC for authentication and AES-CTR for encryption.
|
|
534
|
+
*/
|
|
535
|
+
var EAX = class {
|
|
536
|
+
#key;
|
|
537
|
+
constructor(key) {
|
|
538
|
+
if (key.length !== 16) throw new Error("EAX requires a 16-byte key");
|
|
539
|
+
this.#key = key;
|
|
540
|
+
}
|
|
541
|
+
encrypt(nonce, header, plaintext) {
|
|
542
|
+
const nStar = this.#cmac(0, nonce);
|
|
543
|
+
const hStar = this.#cmac(1, header);
|
|
544
|
+
const ciphertext = aesCtr(this.#key, nStar, plaintext);
|
|
545
|
+
const cStar = this.#cmac(2, ciphertext);
|
|
546
|
+
const tag = new Uint8Array(EAX_TAG_SIZE);
|
|
547
|
+
for (let i = 0; i < EAX_TAG_SIZE; i++) tag[i] = nStar[i] ^ hStar[i] ^ cStar[i];
|
|
548
|
+
return [ciphertext, tag];
|
|
549
|
+
}
|
|
550
|
+
decrypt(nonce, header, ciphertext, tag) {
|
|
551
|
+
const nStar = this.#cmac(0, nonce);
|
|
552
|
+
const hStar = this.#cmac(1, header);
|
|
553
|
+
const cStar = this.#cmac(2, ciphertext);
|
|
554
|
+
const expected = new Uint8Array(EAX_TAG_SIZE);
|
|
555
|
+
for (let i = 0; i < EAX_TAG_SIZE; i++) expected[i] = nStar[i] ^ hStar[i] ^ cStar[i];
|
|
556
|
+
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(tag.slice(0, EAX_TAG_SIZE)))) throw new EAXTagMismatchError();
|
|
557
|
+
return aesCtr(this.#key, nStar, ciphertext);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* AES-CMAC per RFC 4493:
|
|
561
|
+
* input = [0…0 tag] ++ data (one block prefix)
|
|
562
|
+
*/
|
|
563
|
+
#cmac(tagByte, data) {
|
|
564
|
+
const input = new Uint8Array(EAX_BLOCK_SIZE + data.length);
|
|
565
|
+
input[15] = tagByte;
|
|
566
|
+
input.set(data, EAX_BLOCK_SIZE);
|
|
567
|
+
return aesCmac(this.#key, input);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
function aesCtr(key, iv, data) {
|
|
571
|
+
const cipher = createCipheriv("aes-128-ctr", Buffer.from(key), Buffer.from(iv));
|
|
572
|
+
return Buffer.concat([cipher.update(Buffer.from(data)), cipher.final()]);
|
|
573
|
+
}
|
|
574
|
+
function aesEcb(key, block) {
|
|
575
|
+
const cipher = createCipheriv("aes-128-ecb", Buffer.from(key), null);
|
|
576
|
+
cipher.setAutoPadding(false);
|
|
577
|
+
return Buffer.concat([cipher.update(Buffer.from(block)), cipher.final()]);
|
|
578
|
+
}
|
|
579
|
+
function xorBlock(a, b) {
|
|
580
|
+
const out = new Uint8Array(EAX_BLOCK_SIZE);
|
|
581
|
+
for (let i = 0; i < EAX_BLOCK_SIZE; i++) out[i] = a[i] ^ b[i];
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
function shiftLeft1(b) {
|
|
585
|
+
const out = new Uint8Array(EAX_BLOCK_SIZE);
|
|
586
|
+
let carry = 0;
|
|
587
|
+
for (let i = 15; i >= 0; i--) {
|
|
588
|
+
const shifted = (b[i] << 1 | carry) & 255;
|
|
589
|
+
carry = b[i] >> 7;
|
|
590
|
+
out[i] = shifted;
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
var RB = new Uint8Array(EAX_BLOCK_SIZE);
|
|
595
|
+
RB[15] = 135;
|
|
596
|
+
function generateSubkeys(key) {
|
|
597
|
+
const L = aesEcb(key, new Uint8Array(EAX_BLOCK_SIZE));
|
|
598
|
+
let K1 = shiftLeft1(L);
|
|
599
|
+
if ((L[0] & 128) !== 0) K1 = xorBlock(K1, RB);
|
|
600
|
+
let K2 = shiftLeft1(K1);
|
|
601
|
+
if ((K1[0] & 128) !== 0) K2 = xorBlock(K2, RB);
|
|
602
|
+
return [K1, K2];
|
|
603
|
+
}
|
|
604
|
+
function aesCmac(key, message) {
|
|
605
|
+
const [K1, K2] = generateSubkeys(key);
|
|
606
|
+
const n = Math.max(1, Math.ceil(message.length / EAX_BLOCK_SIZE));
|
|
607
|
+
const lastBlockComplete = message.length > 0 && message.length % EAX_BLOCK_SIZE === 0;
|
|
608
|
+
let X = new Uint8Array(EAX_BLOCK_SIZE);
|
|
609
|
+
for (let i = 0; i < n - 1; i++) {
|
|
610
|
+
const block = message.slice(i * EAX_BLOCK_SIZE, (i + 1) * EAX_BLOCK_SIZE);
|
|
611
|
+
X = Uint8Array.from(aesEcb(key, xorBlock(X, block)));
|
|
612
|
+
}
|
|
613
|
+
const lastBlock = new Uint8Array(EAX_BLOCK_SIZE);
|
|
614
|
+
const lastStart = (n - 1) * EAX_BLOCK_SIZE;
|
|
615
|
+
const lastSlice = message.slice(lastStart);
|
|
616
|
+
lastBlock.set(lastSlice);
|
|
617
|
+
let Mn;
|
|
618
|
+
if (lastBlockComplete) Mn = xorBlock(lastBlock, K1);
|
|
619
|
+
else {
|
|
620
|
+
if (lastSlice.length < EAX_BLOCK_SIZE) lastBlock[lastSlice.length] = 128;
|
|
621
|
+
Mn = xorBlock(lastBlock, K2);
|
|
622
|
+
}
|
|
623
|
+
return Uint8Array.from(aesEcb(key, xorBlock(X, Mn)));
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/crypto/crypt.ts
|
|
627
|
+
var FAKE_SIGNATURE_SIZE = 8;
|
|
628
|
+
var IV_ALPHA_SIZE = 10;
|
|
629
|
+
var INIT1_PACKET_TYPE = 8;
|
|
630
|
+
var PACKET_TYPE_MASK = 15;
|
|
631
|
+
var INIT1_MAC = Buffer.from("TS3INIT1");
|
|
632
|
+
/** Precomputed dummy key/nonce matching the TS3 client pre-crypto placeholder. */
|
|
633
|
+
var DUMMY_KEY = Buffer.from("c:\\windows\\syste");
|
|
634
|
+
var DUMMY_NONCE = Buffer.from("m\\firewall32.cpl");
|
|
635
|
+
/**
|
|
636
|
+
* A connection's encryption state. The session material — the IV the keys
|
|
637
|
+
* derive from — lives in private fields, so it shows in no `util.inspect()`
|
|
638
|
+
* or `JSON.stringify()` of anything holding a Crypt.
|
|
639
|
+
*/
|
|
640
|
+
var Crypt = class {
|
|
641
|
+
identity;
|
|
642
|
+
#ivStruct = /* @__PURE__ */ new Uint8Array(0);
|
|
643
|
+
#fakeSignature = new Uint8Array(FAKE_SIGNATURE_SIZE);
|
|
644
|
+
#alpha = /* @__PURE__ */ new Uint8Array(0);
|
|
645
|
+
#cryptoInitComplete = false;
|
|
646
|
+
#cachedKeys = /* @__PURE__ */ new Map();
|
|
647
|
+
constructor(identity) {
|
|
648
|
+
this.identity = identity;
|
|
649
|
+
}
|
|
650
|
+
/** Whether the session keys are set up; until then packets use the dummy key. */
|
|
651
|
+
get cryptoInitComplete() {
|
|
652
|
+
return this.#cryptoInitComplete;
|
|
653
|
+
}
|
|
654
|
+
/** The signature unencrypted packets carry: zeros until crypto init completes. */
|
|
655
|
+
get fakeSignature() {
|
|
656
|
+
return this.#fakeSignature;
|
|
657
|
+
}
|
|
658
|
+
/** The client's random contribution to the IV, sent in clientinitiv; empty before. */
|
|
659
|
+
get alpha() {
|
|
660
|
+
return this.#alpha;
|
|
661
|
+
}
|
|
662
|
+
/** Choose the client's random contribution to the IV, and return it. */
|
|
663
|
+
newAlpha() {
|
|
664
|
+
this.#alpha = new Uint8Array(randomBytes(IV_ALPHA_SIZE));
|
|
665
|
+
return this.#alpha;
|
|
666
|
+
}
|
|
667
|
+
solveRsaChallenge(data, offset, level) {
|
|
668
|
+
if (!Number.isInteger(level) || level < 0 || level > 1e5) throw new CryptoInitError(`RSA challenge level ${level} out of range`);
|
|
669
|
+
const xBytes = data.slice(offset, offset + 64);
|
|
670
|
+
const nBytes = data.slice(offset + 64, offset + 128);
|
|
671
|
+
let y = bytesToBigInt(xBytes);
|
|
672
|
+
const n = bytesToBigInt(nBytes);
|
|
673
|
+
if (n === 0n) throw new CryptoInitError("RSA challenge modulus is zero");
|
|
674
|
+
for (let i = 0; i < level; i++) y = y * y % n;
|
|
675
|
+
return bigIntToBytes(y, 64);
|
|
676
|
+
}
|
|
677
|
+
initCrypto(alpha, beta, omega) {
|
|
678
|
+
const alphaBytes = Buffer.from(alpha, "base64");
|
|
679
|
+
const betaBytes = Buffer.from(beta, "base64");
|
|
680
|
+
const omegaBytes = Buffer.from(omega, "base64");
|
|
681
|
+
let sharedSecret;
|
|
682
|
+
try {
|
|
683
|
+
sharedSecret = this.#getSharedSecret(importPublicKey(omegaBytes));
|
|
684
|
+
} catch (error) {
|
|
685
|
+
throw new CryptoInitError("the server's public key is invalid", { cause: error });
|
|
686
|
+
}
|
|
687
|
+
this.setSharedSecret(alphaBytes, betaBytes, sharedSecret);
|
|
688
|
+
}
|
|
689
|
+
setSharedSecret(alpha, beta, sharedKey) {
|
|
690
|
+
this.#ivStruct = new Uint8Array(IV_ALPHA_SIZE + beta.length);
|
|
691
|
+
for (let i = 0; i < IV_ALPHA_SIZE; i++) {
|
|
692
|
+
const a = sharedKey[i];
|
|
693
|
+
const b = alpha[i];
|
|
694
|
+
this.#ivStruct[i] = ((a !== void 0 ? a : 0) ^ (b !== void 0 ? b : 0)) & 255;
|
|
695
|
+
}
|
|
696
|
+
for (let i = 0; i < beta.length; i++) {
|
|
697
|
+
const a = sharedKey[IV_ALPHA_SIZE + i];
|
|
698
|
+
const b = beta[i];
|
|
699
|
+
this.#ivStruct[IV_ALPHA_SIZE + i] = ((a !== void 0 ? a : 0) ^ (b !== void 0 ? b : 0)) & 255;
|
|
700
|
+
}
|
|
701
|
+
this.#cachedKeys.clear();
|
|
702
|
+
const digest = createHash("sha1").update(this.#ivStruct).digest();
|
|
703
|
+
this.#fakeSignature = new Uint8Array(digest.buffer, digest.byteOffset, FAKE_SIGNATURE_SIZE);
|
|
704
|
+
this.#cryptoInitComplete = true;
|
|
705
|
+
}
|
|
706
|
+
getKeyNonce(fromServer, packetID, generationID, packetType, dummy) {
|
|
707
|
+
if (dummy) return [Uint8Array.from(DUMMY_KEY), Uint8Array.from(DUMMY_NONCE)];
|
|
708
|
+
if (!this.#cryptoInitComplete) throw new CryptoInitError("no session key before crypto init completes");
|
|
709
|
+
const cacheKey = makeCacheKey(fromServer, packetType, generationID);
|
|
710
|
+
let kn = this.#cachedKeys.get(cacheKey);
|
|
711
|
+
if (kn === void 0) {
|
|
712
|
+
const tmpToHash = new Uint8Array(6 + this.#ivStruct.length);
|
|
713
|
+
tmpToHash[0] = fromServer ? 48 : 49;
|
|
714
|
+
tmpToHash[1] = packetType & PACKET_TYPE_MASK;
|
|
715
|
+
new DataView(tmpToHash.buffer).setUint32(2, generationID, false);
|
|
716
|
+
tmpToHash.set(this.#ivStruct, 6);
|
|
717
|
+
const hash = createHash("sha256").update(tmpToHash).digest();
|
|
718
|
+
kn = {
|
|
719
|
+
key: Uint8Array.from(hash.slice(0, 16)),
|
|
720
|
+
nonce: Uint8Array.from(hash.slice(16, 32)),
|
|
721
|
+
gen: generationID
|
|
722
|
+
};
|
|
723
|
+
this.#cachedKeys.set(cacheKey, kn);
|
|
724
|
+
}
|
|
725
|
+
const key = Uint8Array.from(kn.key);
|
|
726
|
+
key[0] = (key[0] !== void 0 ? key[0] : 0) ^ packetID >> 8 & 255;
|
|
727
|
+
key[1] = (key[1] !== void 0 ? key[1] : 0) ^ packetID & 255;
|
|
728
|
+
return [key, kn.nonce];
|
|
729
|
+
}
|
|
730
|
+
encrypt(packetType, packetID, generationID, header, plaintext, dummy, unencrypted) {
|
|
731
|
+
if (packetType === INIT1_PACKET_TYPE) return [plaintext, INIT1_MAC];
|
|
732
|
+
if (unencrypted) return [plaintext, this.#fakeSignature];
|
|
733
|
+
const [key, nonce] = this.getKeyNonce(false, packetID, generationID, packetType, dummy);
|
|
734
|
+
return new EAX(key).encrypt(nonce, header, plaintext);
|
|
735
|
+
}
|
|
736
|
+
decrypt(packetType, packetID, generationID, header, ciphertext, tag, dummy, unencrypted) {
|
|
737
|
+
if (packetType === INIT1_PACKET_TYPE) return ciphertext;
|
|
738
|
+
if (unencrypted) {
|
|
739
|
+
const fsSub = tag.slice(0, FAKE_SIGNATURE_SIZE);
|
|
740
|
+
if (!Buffer.from(fsSub).equals(Buffer.from(this.#fakeSignature))) throw new FakeSignatureMismatchError();
|
|
741
|
+
return ciphertext;
|
|
742
|
+
}
|
|
743
|
+
const [key, nonce] = this.getKeyNonce(true, packetID, generationID, packetType, dummy);
|
|
744
|
+
return new EAX(key).decrypt(nonce, header, ciphertext, tag);
|
|
745
|
+
}
|
|
746
|
+
#getSharedSecret(serverPubKey) {
|
|
747
|
+
const privJwk = identityPrivateKey(this.identity).export({ format: "jwk" });
|
|
748
|
+
const pubJwk = serverPubKey.export({ format: "jwk" });
|
|
749
|
+
const ecdh = createECDH("prime256v1");
|
|
750
|
+
const dBytes = Buffer.from(privJwk.d, "base64url");
|
|
751
|
+
ecdh.setPrivateKey(dBytes);
|
|
752
|
+
const xBytes = base64UrlToBytes(pubJwk.x, 32);
|
|
753
|
+
const yBytes = base64UrlToBytes(pubJwk.y, 32);
|
|
754
|
+
const uncompressed = Buffer.alloc(65);
|
|
755
|
+
uncompressed[0] = 4;
|
|
756
|
+
xBytes.copy(uncompressed, 1);
|
|
757
|
+
yBytes.copy(uncompressed, 33);
|
|
758
|
+
const rawSecret = ecdh.computeSecret(uncompressed);
|
|
759
|
+
return createHash("sha1").update(rawSecret).digest();
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
function makeCacheKey(fromServer, packetType, generationID) {
|
|
763
|
+
let key = 0n;
|
|
764
|
+
if (fromServer) key |= 1n << 40n;
|
|
765
|
+
key |= BigInt(packetType & PACKET_TYPE_MASK) << 32n;
|
|
766
|
+
key |= BigInt(generationID);
|
|
767
|
+
return key;
|
|
768
|
+
}
|
|
769
|
+
function bytesToBigInt(bytes) {
|
|
770
|
+
let result = 0n;
|
|
771
|
+
for (const b of bytes) result = result << 8n | BigInt(b);
|
|
772
|
+
return result;
|
|
773
|
+
}
|
|
774
|
+
function bigIntToBytes(value, size) {
|
|
775
|
+
const result = new Uint8Array(size);
|
|
776
|
+
let v = value;
|
|
777
|
+
for (let i = size - 1; i >= 0; i--) {
|
|
778
|
+
result[i] = Number(v & 255n);
|
|
779
|
+
v >>= 8n;
|
|
780
|
+
}
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
function base64UrlToBytes(b64url, size) {
|
|
784
|
+
const buf = Buffer.from(b64url, "base64url");
|
|
785
|
+
if (buf.length === size) return buf;
|
|
786
|
+
const padded = Buffer.alloc(size);
|
|
787
|
+
buf.copy(padded, size - buf.length);
|
|
788
|
+
return padded;
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/crypto/primitives.ts
|
|
792
|
+
var CURVE25519_KEY_SIZE = 32;
|
|
793
|
+
var CLAMP_MASK_LOW = 248;
|
|
794
|
+
var CLAMP_MASK_HIGH = 127;
|
|
795
|
+
var CLAMP_HIGH_BIT = 64;
|
|
796
|
+
var SHARED_SIGN_BIT = 128;
|
|
797
|
+
var PRIVATE_KEY_TOP_MASK = 127;
|
|
798
|
+
/** Clamp a Curve25519 scalar in-place (required before scalar multiplication). */
|
|
799
|
+
function clampScalar(key) {
|
|
800
|
+
if (key.length < CURVE25519_KEY_SIZE) return;
|
|
801
|
+
key[0] = (key[0] ?? 0) & CLAMP_MASK_LOW;
|
|
802
|
+
key[31] = (key[31] ?? 0) & CLAMP_MASK_HIGH | CLAMP_HIGH_BIT;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Generate an ephemeral Ed25519 key pair for the TS3 handshake.
|
|
806
|
+
* Returns [publicKey (32 bytes, RFC-8032 encoded), privateKey (32 bytes)].
|
|
807
|
+
*/
|
|
808
|
+
function generateTemporaryKey() {
|
|
809
|
+
const privateKey = new Uint8Array(randomBytes(CURVE25519_KEY_SIZE));
|
|
810
|
+
clampScalar(privateKey);
|
|
811
|
+
const scalar = bytesToBigIntLE(privateKey) % ed25519.Point.CURVE().n;
|
|
812
|
+
return [ed25519.Point.BASE.multiply(scalar).toBytes(), Uint8Array.from(privateKey)];
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Sign data with a P-256 private key (SHA-256 hash, ASN.1 DER encoded).
|
|
816
|
+
*/
|
|
817
|
+
function sign(privateKey, data) {
|
|
818
|
+
const signer = createSign("SHA256");
|
|
819
|
+
signer.update(data);
|
|
820
|
+
return signer.sign(privateKey);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Verify a P-256 ECDSA signature (SHA-256 hash, ASN.1 DER encoded).
|
|
824
|
+
*/
|
|
825
|
+
function verifySign(publicKey, data, sig) {
|
|
826
|
+
try {
|
|
827
|
+
const verifier = createVerify("SHA256");
|
|
828
|
+
verifier.update(data);
|
|
829
|
+
return verifier.verify(publicKey, Buffer.from(sig));
|
|
830
|
+
} catch {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* TS3-specific shared secret derivation using Ed25519 point arithmetic.
|
|
836
|
+
* Mirrors NaCl's ge_scalarmult_vartime:
|
|
837
|
+
* 1. Negate the public point
|
|
838
|
+
* 2. Scalar-multiply by clamped private key bytes (NOT reduced mod n)
|
|
839
|
+
* 3. Flip sign bit of result
|
|
840
|
+
* 4. SHA-512 the resulting point bytes
|
|
841
|
+
*
|
|
842
|
+
* The scalar must NOT be reduced mod n: Ed25519 has cofactor 8, so the server's
|
|
843
|
+
* public key may have a small-order component, and P*s ≠ P*(s mod n) in that case.
|
|
844
|
+
* We use scalarMultFull to handle scalars >= n without losing the cofactor term.
|
|
845
|
+
*/
|
|
846
|
+
function getSharedSecret2(publicKeyBytes, privateKeyBytes) {
|
|
847
|
+
if (publicKeyBytes.length !== CURVE25519_KEY_SIZE || privateKeyBytes.length !== CURVE25519_KEY_SIZE) throw new Error("invalid key length");
|
|
848
|
+
const privCopy = Uint8Array.from(privateKeyBytes);
|
|
849
|
+
privCopy[31] = (privCopy[31] ?? 0) & PRIVATE_KEY_TOP_MASK;
|
|
850
|
+
const rawScalar = bytesToBigIntLE(privCopy);
|
|
851
|
+
const sharedBytes = scalarMultFull(ed25519.Point.fromBytes(publicKeyBytes).negate(), rawScalar).toBytes();
|
|
852
|
+
sharedBytes[31] = (sharedBytes[31] ?? 0) ^ SHARED_SIGN_BIT;
|
|
853
|
+
return createHash("sha512").update(sharedBytes).digest();
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Multiply an Ed25519 point by a scalar that may be >= curve order n.
|
|
857
|
+
* Decomposes as: P*s = P*(s mod n) + (P*n)*q where q = floor(s/n).
|
|
858
|
+
* This preserves the small-order component (cofactor 8) that would be
|
|
859
|
+
* lost by reducing mod n first.
|
|
860
|
+
*
|
|
861
|
+
* For a clamped 255-bit scalar, q is at most 7 (since maxScalar / n < 8),
|
|
862
|
+
* so the second term is computed with at most one extra scalar multiplication.
|
|
863
|
+
*/
|
|
864
|
+
function scalarMultFull(point, scalar) {
|
|
865
|
+
const n = ed25519.Point.CURVE().n;
|
|
866
|
+
if (scalar < n) return scalar === 0n ? ed25519.Point.ZERO : point.multiply(scalar);
|
|
867
|
+
const remainder = scalar % n;
|
|
868
|
+
const quotient = scalar / n;
|
|
869
|
+
const mainPart = remainder === 0n ? ed25519.Point.ZERO : point.multiply(remainder);
|
|
870
|
+
const cofactorPart = point.multiply(n - 1n).add(point).multiply(quotient);
|
|
871
|
+
return mainPart.add(cofactorPart);
|
|
872
|
+
}
|
|
873
|
+
/** Interpret a little-endian byte array as an unsigned BigInt. */
|
|
874
|
+
function bytesToBigIntLE(bytes) {
|
|
875
|
+
let result = 0n;
|
|
876
|
+
for (let i = bytes.length - 1; i >= 0; i--) result = result << 8n | BigInt(bytes[i]);
|
|
877
|
+
return result;
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
//#region src/address.ts
|
|
881
|
+
/** Parse a TeamSpeak address without confusing an IPv6 colon for a port separator. */
|
|
882
|
+
function splitHostPort(address, defaultPort = "9987") {
|
|
883
|
+
const input = address.trim();
|
|
884
|
+
if (input.startsWith("[")) {
|
|
885
|
+
const closingBracket = input.indexOf("]");
|
|
886
|
+
if (closingBracket > 0) {
|
|
887
|
+
const host = input.slice(1, closingBracket);
|
|
888
|
+
const suffix = input.slice(closingBracket + 1);
|
|
889
|
+
if (suffix.startsWith(":") && /^\d+$/.test(suffix.slice(1))) return {
|
|
890
|
+
host,
|
|
891
|
+
port: suffix.slice(1)
|
|
892
|
+
};
|
|
893
|
+
if (!suffix) return {
|
|
894
|
+
host,
|
|
895
|
+
port: defaultPort
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (isIP(input) === 6) return {
|
|
900
|
+
host: input,
|
|
901
|
+
port: defaultPort
|
|
902
|
+
};
|
|
903
|
+
const lastColon = input.lastIndexOf(":");
|
|
904
|
+
if (lastColon < 0) return {
|
|
905
|
+
host: input,
|
|
906
|
+
port: defaultPort
|
|
907
|
+
};
|
|
908
|
+
const candidatePort = input.slice(lastColon + 1);
|
|
909
|
+
if (/^\d+$/.test(candidatePort) && input.indexOf(":") === lastColon) return {
|
|
910
|
+
host: input.slice(0, lastColon),
|
|
911
|
+
port: candidatePort
|
|
912
|
+
};
|
|
913
|
+
return {
|
|
914
|
+
host: input,
|
|
915
|
+
port: defaultPort
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/** Characters no host name or IP address contains, whitespace among them. */
|
|
919
|
+
var NOT_IN_A_HOST = /[\s\p{Cc}/?#@[\]\\]/u;
|
|
920
|
+
/**
|
|
921
|
+
* Parse an address to connect to, and check it can be one: a host, which
|
|
922
|
+
* contains nothing a host name or IP address cannot, and a port from 1 to
|
|
923
|
+
* 65535. Throws an InvalidAddressError otherwise.
|
|
924
|
+
*/
|
|
925
|
+
function parseConnectAddress(address) {
|
|
926
|
+
const { host, port } = splitHostPort(address);
|
|
927
|
+
if (host === "") throw new InvalidAddressError(address, "no host");
|
|
928
|
+
if (NOT_IN_A_HOST.test(host) || host.includes(":") && isIP(host) !== 6) throw new InvalidAddressError(address, "not a host name, IP address or host:port");
|
|
929
|
+
const portNumber = Number(port);
|
|
930
|
+
if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) throw new InvalidAddressError(address, `port ${port} is not from 1 to 65535`);
|
|
931
|
+
return {
|
|
932
|
+
host,
|
|
933
|
+
port,
|
|
934
|
+
portNumber
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
function joinHostPort(host, port) {
|
|
938
|
+
const normalizedHost = stripBrackets(host);
|
|
939
|
+
return isIP(normalizedHost) === 6 || normalizedHost.includes(":") ? `[${normalizedHost}]:${port}` : `${normalizedHost}:${port}`;
|
|
940
|
+
}
|
|
941
|
+
function isIpAddress(host) {
|
|
942
|
+
return isIP(stripBrackets(host)) !== 0;
|
|
943
|
+
}
|
|
944
|
+
function stripBrackets(host) {
|
|
945
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
946
|
+
}
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region src/discovery/resolver.ts
|
|
949
|
+
var TS_DNS_DEFAULT_PORT = 41144;
|
|
950
|
+
var NICKNAME_LOOKUP_URL = "https://named.myteamspeak.com/lookup";
|
|
951
|
+
var CACHE_TTL_MS = 6e5;
|
|
952
|
+
/**
|
|
953
|
+
* How long resolution may take in all; past it, the address is used as given.
|
|
954
|
+
* A Client holds any resolver, its own or a custom one, to the same budget.
|
|
955
|
+
*
|
|
956
|
+
* @internal
|
|
957
|
+
*/
|
|
958
|
+
var RESOLVE_BUDGET_MS = 1e4;
|
|
959
|
+
/** How long one TSDNS server may take to answer. */
|
|
960
|
+
var TSDNS_TIMEOUT_MS = 3e3;
|
|
961
|
+
var Resolver = class {
|
|
962
|
+
#cache = /* @__PURE__ */ new Map();
|
|
963
|
+
#resolveNicknames;
|
|
964
|
+
constructor(options = {}) {
|
|
965
|
+
this.#resolveNicknames = options.resolveNicknames ?? false;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Resolve as the TeamSpeak application does: a server nickname (only with
|
|
969
|
+
* `resolveNicknames`), then SRV, then TSDNS, then the address as given. The
|
|
970
|
+
* whole resolution is bounded: past the budget, or when a step fails, the
|
|
971
|
+
* address as given is the answer.
|
|
972
|
+
*/
|
|
973
|
+
async resolve(inputAddr, options = {}) {
|
|
974
|
+
if (!inputAddr) throw new Error("empty address");
|
|
975
|
+
const budget = AbortSignal.timeout(RESOLVE_BUDGET_MS);
|
|
976
|
+
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget;
|
|
977
|
+
try {
|
|
978
|
+
return await this.#resolve(inputAddr, signal, true);
|
|
979
|
+
} catch (error) {
|
|
980
|
+
options.signal?.throwIfAborted();
|
|
981
|
+
if (!budget.aborted) throw error;
|
|
982
|
+
const { host, port } = splitHostPort(inputAddr);
|
|
983
|
+
return [{
|
|
984
|
+
addr: joinHostPort(host, port),
|
|
985
|
+
source: "Direct"
|
|
986
|
+
}];
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
async #resolve(inputAddr, signal, nicknames) {
|
|
990
|
+
const cached = this.#getValidCache(inputAddr);
|
|
991
|
+
if (cached) return cached;
|
|
992
|
+
const { host, port } = splitHostPort(inputAddr);
|
|
993
|
+
if (isIpAddress(host)) return [{
|
|
994
|
+
addr: joinHostPort(host, port),
|
|
995
|
+
source: "Direct"
|
|
996
|
+
}];
|
|
997
|
+
if (nicknames && this.#resolveNicknames && !host.includes(".") && host !== "localhost") {
|
|
998
|
+
const nickAddr = await resolveNickname(host, signal);
|
|
999
|
+
signal.throwIfAborted();
|
|
1000
|
+
if (nickAddr) return this.#resolve(nickAddr, signal, false);
|
|
1001
|
+
}
|
|
1002
|
+
const srvResults = await this.#resolveSRV(host, signal);
|
|
1003
|
+
if (srvResults) return this.#setCache(inputAddr, srvResults);
|
|
1004
|
+
const domainList = getDomainList(host);
|
|
1005
|
+
const tsdnsSrv = await this.#resolveTSDNSSRV(domainList, host, signal);
|
|
1006
|
+
if (tsdnsSrv) return this.#setCache(inputAddr, [{
|
|
1007
|
+
addr: tsdnsSrv,
|
|
1008
|
+
source: "TSDNS-SRV"
|
|
1009
|
+
}]);
|
|
1010
|
+
const tsdnsDirect = await this.#resolveTSDNSDirect(domainList, host, signal);
|
|
1011
|
+
if (tsdnsDirect) return this.#setCache(inputAddr, [{
|
|
1012
|
+
addr: tsdnsDirect,
|
|
1013
|
+
source: "TSDNS-Direct"
|
|
1014
|
+
}]);
|
|
1015
|
+
signal.throwIfAborted();
|
|
1016
|
+
const fallback = [{
|
|
1017
|
+
addr: joinHostPort(host, port),
|
|
1018
|
+
source: "Direct"
|
|
1019
|
+
}];
|
|
1020
|
+
return this.#setCache(inputAddr, fallback);
|
|
1021
|
+
}
|
|
1022
|
+
#getValidCache(inputAddr) {
|
|
1023
|
+
const cached = this.#cache.get(inputAddr);
|
|
1024
|
+
if (!cached || cached.length === 0) return null;
|
|
1025
|
+
const first = cached[0];
|
|
1026
|
+
if (first.expiry !== void 0 && Date.now() > first.expiry.getTime()) return null;
|
|
1027
|
+
return cached;
|
|
1028
|
+
}
|
|
1029
|
+
#setCache(key, results) {
|
|
1030
|
+
const expiry = new Date(Date.now() + CACHE_TTL_MS);
|
|
1031
|
+
const withExpiry = results.map((r) => ({
|
|
1032
|
+
...r,
|
|
1033
|
+
expiry
|
|
1034
|
+
}));
|
|
1035
|
+
this.#cache.set(key, withExpiry);
|
|
1036
|
+
return withExpiry;
|
|
1037
|
+
}
|
|
1038
|
+
async #resolveSRV(host, signal) {
|
|
1039
|
+
try {
|
|
1040
|
+
const srvs = await abortable(resolveSrv(`_ts3._udp.${host}`), signal);
|
|
1041
|
+
if (!srvs || srvs.length === 0) return null;
|
|
1042
|
+
return srvs.map((srv) => ({
|
|
1043
|
+
addr: joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)),
|
|
1044
|
+
source: "SRV"
|
|
1045
|
+
}));
|
|
1046
|
+
} catch {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
async #resolveTSDNSSRV(domains, queryHost, signal) {
|
|
1051
|
+
for (const domain of domains) try {
|
|
1052
|
+
const srvs = await abortable(resolveSrv(`_tsdns._tcp.${domain}`), signal);
|
|
1053
|
+
if (!srvs || srvs.length === 0) continue;
|
|
1054
|
+
for (const srv of srvs) {
|
|
1055
|
+
const result = await queryTSDNS(joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)), queryHost, signal);
|
|
1056
|
+
if (result) return result;
|
|
1057
|
+
}
|
|
1058
|
+
} catch {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
async #resolveTSDNSDirect(domains, queryHost, signal) {
|
|
1064
|
+
return (await Promise.all(domains.map((domain) => queryTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, signal)))).find((answer) => answer !== null) ?? null;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
/** @internal Ask the nickname service for `nickname`'s address. */
|
|
1068
|
+
async function resolveNickname(nickname, signal) {
|
|
1069
|
+
try {
|
|
1070
|
+
const url = new URL(NICKNAME_LOOKUP_URL);
|
|
1071
|
+
url.searchParams.set("name", nickname);
|
|
1072
|
+
const init = signal ? { signal } : {};
|
|
1073
|
+
const resp = await fetch(url.toString(), init);
|
|
1074
|
+
if (!resp.ok) return null;
|
|
1075
|
+
return (await readLimited(resp, 8192)).split("\n")[0]?.trim() || null;
|
|
1076
|
+
} catch {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/** A response body's first `limit` bytes, as text; the rest is not read. */
|
|
1081
|
+
async function readLimited(resp, limit) {
|
|
1082
|
+
if (resp.body === null) return "";
|
|
1083
|
+
const reader = resp.body.getReader();
|
|
1084
|
+
const chunks = [];
|
|
1085
|
+
let size = 0;
|
|
1086
|
+
try {
|
|
1087
|
+
while (size < limit) {
|
|
1088
|
+
const { done, value } = await reader.read();
|
|
1089
|
+
if (done) break;
|
|
1090
|
+
const part = value.subarray(0, limit - size);
|
|
1091
|
+
chunks.push(part);
|
|
1092
|
+
size += part.length;
|
|
1093
|
+
}
|
|
1094
|
+
} finally {
|
|
1095
|
+
reader.cancel().catch(() => {});
|
|
1096
|
+
}
|
|
1097
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1098
|
+
}
|
|
1099
|
+
/** @internal Ask the TSDNS server at `tsdnsAddr` for `queryHost`'s address. */
|
|
1100
|
+
function queryTSDNS(tsdnsAddr, queryHost, signal) {
|
|
1101
|
+
const [host, portStr] = splitHostPortParts(tsdnsAddr);
|
|
1102
|
+
const port = parseInt(portStr, 10);
|
|
1103
|
+
return new Promise((resolve) => {
|
|
1104
|
+
if (signal?.aborted) return resolve(null);
|
|
1105
|
+
const finish = (answer) => {
|
|
1106
|
+
clearTimeout(timeout);
|
|
1107
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1108
|
+
socket.destroy();
|
|
1109
|
+
resolve(answer);
|
|
1110
|
+
};
|
|
1111
|
+
const onAbort = () => finish(null);
|
|
1112
|
+
const timeout = setTimeout(() => finish(null), TSDNS_TIMEOUT_MS);
|
|
1113
|
+
const socket = createConnection({
|
|
1114
|
+
host,
|
|
1115
|
+
port,
|
|
1116
|
+
timeout: 2e3
|
|
1117
|
+
}, () => {
|
|
1118
|
+
socket.write(`${queryHost}\n`);
|
|
1119
|
+
});
|
|
1120
|
+
let buf = "";
|
|
1121
|
+
socket.on("data", (chunk) => {
|
|
1122
|
+
buf += chunk.toString("latin1");
|
|
1123
|
+
const idx = buf.indexOf("\n");
|
|
1124
|
+
if (idx >= 0 && idx < 8192) {
|
|
1125
|
+
const line = Buffer.from(buf.slice(0, idx), "latin1").toString("utf8").trim();
|
|
1126
|
+
finish(!line || line === "404" || line === "errors" ? null : line);
|
|
1127
|
+
} else if (buf.length >= 8192) finish(null);
|
|
1128
|
+
});
|
|
1129
|
+
socket.on("error", () => finish(null));
|
|
1130
|
+
socket.on("timeout", () => finish(null));
|
|
1131
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
function splitHostPortParts(addr) {
|
|
1135
|
+
const { host, port } = splitHostPort(addr);
|
|
1136
|
+
return [host, port];
|
|
1137
|
+
}
|
|
1138
|
+
function getDomainList(host) {
|
|
1139
|
+
const parts = host.split(".");
|
|
1140
|
+
const list = [];
|
|
1141
|
+
for (let i = 0; i < parts.length - 1; i++) list.push(parts.slice(i).join("."));
|
|
1142
|
+
return list.slice(0, 3);
|
|
1143
|
+
}
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region src/transport/packet.ts
|
|
1146
|
+
var PacketType = /* @__PURE__ */ function(PacketType) {
|
|
1147
|
+
PacketType[PacketType["Voice"] = 0] = "Voice";
|
|
1148
|
+
PacketType[PacketType["VoiceWhisper"] = 1] = "VoiceWhisper";
|
|
1149
|
+
PacketType[PacketType["Command"] = 2] = "Command";
|
|
1150
|
+
PacketType[PacketType["CommandLow"] = 3] = "CommandLow";
|
|
1151
|
+
PacketType[PacketType["Ping"] = 4] = "Ping";
|
|
1152
|
+
PacketType[PacketType["Pong"] = 5] = "Pong";
|
|
1153
|
+
PacketType[PacketType["Ack"] = 6] = "Ack";
|
|
1154
|
+
PacketType[PacketType["AckLow"] = 7] = "AckLow";
|
|
1155
|
+
PacketType[PacketType["Init1"] = 8] = "Init1";
|
|
1156
|
+
return PacketType;
|
|
1157
|
+
}({});
|
|
1158
|
+
var PacketFlags = /* @__PURE__ */ function(PacketFlags) {
|
|
1159
|
+
PacketFlags[PacketFlags["Fragmented"] = 16] = "Fragmented";
|
|
1160
|
+
PacketFlags[PacketFlags["NewProtocol"] = 32] = "NewProtocol";
|
|
1161
|
+
PacketFlags[PacketFlags["Compressed"] = 64] = "Compressed";
|
|
1162
|
+
PacketFlags[PacketFlags["Unencrypted"] = 128] = "Unencrypted";
|
|
1163
|
+
return PacketFlags;
|
|
1164
|
+
}({});
|
|
1165
|
+
function packetType(p) {
|
|
1166
|
+
return p.typeFlagged & 15;
|
|
1167
|
+
}
|
|
1168
|
+
function packetFlags(p) {
|
|
1169
|
+
return p.typeFlagged & 240;
|
|
1170
|
+
}
|
|
1171
|
+
/** Build the 5-byte client-to-server header: [packetID(2), clientID(2), typeFlagged(1)]. */
|
|
1172
|
+
function buildC2SHeader(p) {
|
|
1173
|
+
const header = /* @__PURE__ */ new Uint8Array(5);
|
|
1174
|
+
const view = new DataView(header.buffer);
|
|
1175
|
+
view.setUint16(0, p.id, false);
|
|
1176
|
+
view.setUint16(2, p.clientID, false);
|
|
1177
|
+
header[4] = p.typeFlagged;
|
|
1178
|
+
return header;
|
|
1179
|
+
}
|
|
1180
|
+
/** Parse a 3-byte server-to-client header. */
|
|
1181
|
+
function parseS2CHeader(raw) {
|
|
1182
|
+
return {
|
|
1183
|
+
id: new DataView(raw.buffer, raw.byteOffset, raw.byteLength).getUint16(0, false),
|
|
1184
|
+
typeFlagged: raw[2]
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
1188
|
+
//#region src/transport/generation-window.ts
|
|
1189
|
+
var GenerationWindow = class {
|
|
1190
|
+
#mappedBaseOffset = 0;
|
|
1191
|
+
#generation = 0;
|
|
1192
|
+
#mod;
|
|
1193
|
+
#receiveWindow;
|
|
1194
|
+
constructor(mod, windowSize) {
|
|
1195
|
+
this.#mod = mod;
|
|
1196
|
+
this.#receiveWindow = windowSize;
|
|
1197
|
+
}
|
|
1198
|
+
get generation() {
|
|
1199
|
+
return this.#generation;
|
|
1200
|
+
}
|
|
1201
|
+
advance(amount) {
|
|
1202
|
+
if (amount <= 0) return;
|
|
1203
|
+
const newBaseOffset = this.#mappedBaseOffset + amount;
|
|
1204
|
+
const genStep = Math.floor(newBaseOffset / this.#mod);
|
|
1205
|
+
if (genStep > 0) this.#generation = Math.min(this.#generation + genStep, 4294967295);
|
|
1206
|
+
this.#mappedBaseOffset = newBaseOffset % this.#mod;
|
|
1207
|
+
}
|
|
1208
|
+
advanceToExcluded(mappedValue) {
|
|
1209
|
+
let moveDist = mappedValue - this.#mappedBaseOffset;
|
|
1210
|
+
if (moveDist < 0) moveDist += this.#mod;
|
|
1211
|
+
this.advance(moveDist + 1);
|
|
1212
|
+
}
|
|
1213
|
+
syncTo(mappedValue) {
|
|
1214
|
+
let moveDist = mappedValue - this.#mappedBaseOffset;
|
|
1215
|
+
if (moveDist < 0) moveDist += this.#mod;
|
|
1216
|
+
this.advance(moveDist);
|
|
1217
|
+
}
|
|
1218
|
+
isInWindow(mappedValue) {
|
|
1219
|
+
const maxOffset = this.#mappedBaseOffset + this.#receiveWindow;
|
|
1220
|
+
if (maxOffset < this.#mod) return mappedValue >= this.#mappedBaseOffset && mappedValue < maxOffset;
|
|
1221
|
+
return mappedValue >= this.#mappedBaseOffset || mappedValue < maxOffset - this.#mod;
|
|
1222
|
+
}
|
|
1223
|
+
mappedToIndex(mappedValue) {
|
|
1224
|
+
if (this.#isNextGen(mappedValue)) return mappedValue + this.#mod - this.#mappedBaseOffset;
|
|
1225
|
+
return mappedValue - this.#mappedBaseOffset;
|
|
1226
|
+
}
|
|
1227
|
+
isOldPacket(mappedValue) {
|
|
1228
|
+
return this.mappedToIndex(mappedValue) < 0;
|
|
1229
|
+
}
|
|
1230
|
+
isFuturePacket(mappedValue) {
|
|
1231
|
+
return this.mappedToIndex(mappedValue) >= this.#receiveWindow;
|
|
1232
|
+
}
|
|
1233
|
+
#isNextGen(mappedValue) {
|
|
1234
|
+
return this.#mappedBaseOffset > this.#mod - this.#receiveWindow && mappedValue < this.#mappedBaseOffset + this.#receiveWindow - this.#mod;
|
|
1235
|
+
}
|
|
1236
|
+
getGeneration(mappedValue) {
|
|
1237
|
+
if (this.#isNextGen(mappedValue)) return this.#generation + 1;
|
|
1238
|
+
return this.#generation;
|
|
1239
|
+
}
|
|
1240
|
+
reset() {
|
|
1241
|
+
this.#mappedBaseOffset = 0;
|
|
1242
|
+
this.#generation = 0;
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
//#endregion
|
|
1246
|
+
//#region src/transport/quicklz.ts
|
|
1247
|
+
var TABLE_SIZE = 4096;
|
|
1248
|
+
/**
|
|
1249
|
+
* The largest decompressed command accepted. A command is reassembled from at
|
|
1250
|
+
* most a receive window of fragments, and the largest real answers (a full
|
|
1251
|
+
* permission list, the welcome channel list of a big server) are a few
|
|
1252
|
+
* hundred kilobytes; a header declaring more is refused before anything is
|
|
1253
|
+
* allocated.
|
|
1254
|
+
*/
|
|
1255
|
+
var MAX_DECOMPRESSED_SIZE = 1 << 20;
|
|
1256
|
+
/**
|
|
1257
|
+
* How much larger than its compressed bytes a level-1 block can be. The most
|
|
1258
|
+
* a 3-byte back-reference yields is 255 bytes, and every 32 entries also cost
|
|
1259
|
+
* a 4-byte control word, which bounds the ratio at about 82; a block declaring
|
|
1260
|
+
* more cannot be honest, and is refused before its output is allocated.
|
|
1261
|
+
*/
|
|
1262
|
+
var MAX_EXPANSION = 128;
|
|
1263
|
+
/** A QuickLZ block that cannot be decompressed: truncated, oversized or inconsistent. */
|
|
1264
|
+
var QuickLZError = class extends Error {
|
|
1265
|
+
constructor(message) {
|
|
1266
|
+
super(`QuickLZ: ${message}`);
|
|
1267
|
+
this.name = "QuickLZError";
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
/**
|
|
1271
|
+
* QuickLZ level 1 decompression, as the server compresses large commands.
|
|
1272
|
+
*
|
|
1273
|
+
* The input comes from the network, so every read and every back-reference is
|
|
1274
|
+
* checked, and malformed input throws a QuickLZError rather than reading out
|
|
1275
|
+
* of range or producing a partly filled buffer.
|
|
1276
|
+
*/
|
|
1277
|
+
var Qlz = class {
|
|
1278
|
+
#hashtable = new Int32Array(TABLE_SIZE);
|
|
1279
|
+
decompress(data) {
|
|
1280
|
+
const { headerLen, decompressedSize, flags } = parseQlzHeader(data);
|
|
1281
|
+
if ((flags & 1) === 0) {
|
|
1282
|
+
if (headerLen + decompressedSize > data.length) throw new QuickLZError("stored block is shorter than its declared size");
|
|
1283
|
+
return data.slice(headerLen, headerLen + decompressedSize);
|
|
1284
|
+
}
|
|
1285
|
+
if (decompressedSize > (data.length - headerLen) * MAX_EXPANSION) throw new QuickLZError(`declared size ${decompressedSize} is more than ${data.length - headerLen} bytes can expand to`);
|
|
1286
|
+
const dest = new Uint8Array(decompressedSize);
|
|
1287
|
+
this.#hashtable.fill(0);
|
|
1288
|
+
const state = {
|
|
1289
|
+
control: 1,
|
|
1290
|
+
sourcePos: headerLen,
|
|
1291
|
+
destPos: 0,
|
|
1292
|
+
nextHashed: 0
|
|
1293
|
+
};
|
|
1294
|
+
while (state.destPos < decompressedSize) {
|
|
1295
|
+
this.#ensureControl(data, state);
|
|
1296
|
+
if ((state.control & 1) !== 0) this.#processReference(data, dest, state);
|
|
1297
|
+
else if (this.#processLiteral(data, dest, decompressedSize, state)) break;
|
|
1298
|
+
}
|
|
1299
|
+
if (state.destPos !== decompressedSize) throw new QuickLZError("block ends early");
|
|
1300
|
+
return dest;
|
|
1301
|
+
}
|
|
1302
|
+
#ensureControl(data, st) {
|
|
1303
|
+
if (st.control !== 1) return;
|
|
1304
|
+
if (st.sourcePos + 4 > data.length) throw new QuickLZError("block ends early");
|
|
1305
|
+
st.control = (data[st.sourcePos] | data[st.sourcePos + 1] << 8 | data[st.sourcePos + 2] << 16 | data[st.sourcePos + 3] << 24) >>> 0;
|
|
1306
|
+
st.sourcePos += 4;
|
|
1307
|
+
}
|
|
1308
|
+
#processReference(data, dest, st) {
|
|
1309
|
+
st.control = st.control >>> 1 >>> 0;
|
|
1310
|
+
if (st.sourcePos + 2 > data.length) throw new QuickLZError("block ends early");
|
|
1311
|
+
const b1 = data[st.sourcePos];
|
|
1312
|
+
const b2 = data[st.sourcePos + 1];
|
|
1313
|
+
st.sourcePos += 2;
|
|
1314
|
+
const hash = b1 >> 4 | b2 << 4;
|
|
1315
|
+
let matchlen = b1 & 15;
|
|
1316
|
+
if (matchlen !== 0) matchlen += 2;
|
|
1317
|
+
else {
|
|
1318
|
+
if (st.sourcePos >= data.length) throw new QuickLZError("block ends early");
|
|
1319
|
+
matchlen = data[st.sourcePos];
|
|
1320
|
+
st.sourcePos++;
|
|
1321
|
+
}
|
|
1322
|
+
const offset = this.#hashtable[hash];
|
|
1323
|
+
if (offset >= st.destPos) throw new QuickLZError("back-reference points ahead of the output");
|
|
1324
|
+
if (st.destPos + matchlen > dest.length) throw new QuickLZError("back-reference overruns the declared size");
|
|
1325
|
+
for (let i = 0; i < matchlen; i++) {
|
|
1326
|
+
dest[st.destPos] = dest[offset + i];
|
|
1327
|
+
st.destPos++;
|
|
1328
|
+
}
|
|
1329
|
+
const end = st.destPos + 1 - matchlen;
|
|
1330
|
+
this.#updateHashtable(dest, st, end);
|
|
1331
|
+
st.nextHashed = st.destPos;
|
|
1332
|
+
}
|
|
1333
|
+
/** Copy literals; returns true once the tail of the block has been copied. */
|
|
1334
|
+
#processLiteral(data, dest, decompressedSize, st) {
|
|
1335
|
+
const threshold = Math.max(decompressedSize, 10) - 10;
|
|
1336
|
+
if (st.destPos >= threshold) {
|
|
1337
|
+
while (st.destPos < decompressedSize) {
|
|
1338
|
+
if (st.control === 1) {
|
|
1339
|
+
if (st.sourcePos + 4 > data.length) throw new QuickLZError("block ends early");
|
|
1340
|
+
st.control = (data[st.sourcePos] | data[st.sourcePos + 1] << 8 | data[st.sourcePos + 2] << 16 | data[st.sourcePos + 3] << 24) >>> 0;
|
|
1341
|
+
st.sourcePos += 4;
|
|
1342
|
+
}
|
|
1343
|
+
if (st.sourcePos >= data.length) throw new QuickLZError("block ends early");
|
|
1344
|
+
dest[st.destPos++] = data[st.sourcePos++];
|
|
1345
|
+
st.control = st.control >>> 1 >>> 0;
|
|
1346
|
+
}
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
if (st.sourcePos >= data.length) throw new QuickLZError("block ends early");
|
|
1350
|
+
dest[st.destPos++] = data[st.sourcePos++];
|
|
1351
|
+
st.control = st.control >>> 1 >>> 0;
|
|
1352
|
+
const end = Math.max(st.destPos - 2, 0);
|
|
1353
|
+
this.#updateHashtable(dest, st, end);
|
|
1354
|
+
if (st.nextHashed < end) st.nextHashed = end;
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
#updateHashtable(dest, st, end) {
|
|
1358
|
+
while (st.nextHashed < end) {
|
|
1359
|
+
if (st.nextHashed + 3 > dest.length) break;
|
|
1360
|
+
const v = (dest[st.nextHashed] | dest[st.nextHashed + 1] << 8 | dest[st.nextHashed + 2] << 16) >>> 0;
|
|
1361
|
+
const hash = (v >> 12 ^ v) & 4095;
|
|
1362
|
+
this.#hashtable[hash] = st.nextHashed;
|
|
1363
|
+
st.nextHashed++;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
function parseQlzHeader(data) {
|
|
1368
|
+
if (data.length < 3) throw new QuickLZError("data too short");
|
|
1369
|
+
const flags = data[0];
|
|
1370
|
+
if ((flags >> 2 & 3) !== 1) throw new QuickLZError("only level 1 is supported");
|
|
1371
|
+
const long = (flags & 2) !== 0;
|
|
1372
|
+
const headerLen = long ? 9 : 3;
|
|
1373
|
+
if (data.length < headerLen) throw new QuickLZError("data too short for header");
|
|
1374
|
+
const decompressedSize = long ? (data[5] | data[6] << 8 | data[7] << 16 | data[8] << 24) >>> 0 : data[2];
|
|
1375
|
+
if (decompressedSize > 1048576) throw new QuickLZError(`declared size ${decompressedSize} exceeds ${MAX_DECOMPRESSED_SIZE}`);
|
|
1376
|
+
return {
|
|
1377
|
+
headerLen,
|
|
1378
|
+
decompressedSize,
|
|
1379
|
+
flags
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
//#endregion
|
|
1383
|
+
//#region src/handshake/crypt-handshake.ts
|
|
1384
|
+
var INIT_VERSION = 1566914096;
|
|
1385
|
+
var INIT_STEP_LEN = 21;
|
|
1386
|
+
/** The RSA challenge each connection attempt, by its Crypt, has answered. */
|
|
1387
|
+
var answeredChallenges = /* @__PURE__ */ new WeakMap();
|
|
1388
|
+
/**
|
|
1389
|
+
* Handle the TS3INIT1 handshake steps.
|
|
1390
|
+
* Returns the response bytes to send, or null if nothing should be sent.
|
|
1391
|
+
* Throws a CryptoInitError for a step that cannot be answered.
|
|
1392
|
+
*/
|
|
1393
|
+
function processInit1(crypt, data) {
|
|
1394
|
+
if (data === null || data.length >= 1 && data[0] === 127) return buildInit1StartPacket();
|
|
1395
|
+
if (data.length === 0) throw new CryptoInitError("empty Init1 packet");
|
|
1396
|
+
switch (data[0]) {
|
|
1397
|
+
case 0: return buildInit1Step1Packet(data);
|
|
1398
|
+
case 1: return buildInit1Step2Packet(data);
|
|
1399
|
+
case 2: return buildInit1Step3Packet(data);
|
|
1400
|
+
case 3: return buildInit1Step4Packet(crypt, data);
|
|
1401
|
+
default: return null;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function buildInit1StartPacket() {
|
|
1405
|
+
const sendData = /* @__PURE__ */ new Uint8Array(21);
|
|
1406
|
+
const view = new DataView(sendData.buffer);
|
|
1407
|
+
view.setUint32(0, INIT_VERSION, false);
|
|
1408
|
+
sendData[4] = 0;
|
|
1409
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
1410
|
+
const clampedNow = Math.max(0, Math.min(nowSec, 4294967295));
|
|
1411
|
+
view.setUint32(5, clampedNow, false);
|
|
1412
|
+
const rng = randomBytes(4);
|
|
1413
|
+
sendData.set(rng, 9);
|
|
1414
|
+
return sendData;
|
|
1415
|
+
}
|
|
1416
|
+
function buildInit1Step1Packet(data) {
|
|
1417
|
+
if (data.length !== INIT_STEP_LEN) return null;
|
|
1418
|
+
const sendData = /* @__PURE__ */ new Uint8Array(21);
|
|
1419
|
+
sendData[0] = 1;
|
|
1420
|
+
const tsRand = data[9] | data[10] << 8 | data[11] << 16 | data[12] << 24;
|
|
1421
|
+
new DataView(sendData.buffer).setUint32(17, tsRand >>> 0, false);
|
|
1422
|
+
return sendData;
|
|
1423
|
+
}
|
|
1424
|
+
function buildInit1Step2Packet(data) {
|
|
1425
|
+
if (data.length !== INIT_STEP_LEN) return null;
|
|
1426
|
+
const sendData = /* @__PURE__ */ new Uint8Array(25);
|
|
1427
|
+
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
1428
|
+
sendData[4] = 2;
|
|
1429
|
+
sendData.set(data.slice(1, 21), 5);
|
|
1430
|
+
return sendData;
|
|
1431
|
+
}
|
|
1432
|
+
function buildInit1Step3Packet(data) {
|
|
1433
|
+
if (data.length !== 25) return null;
|
|
1434
|
+
const sendData = /* @__PURE__ */ new Uint8Array(233);
|
|
1435
|
+
sendData[0] = 3;
|
|
1436
|
+
sendData[64] = 1;
|
|
1437
|
+
sendData[128] = 1;
|
|
1438
|
+
new DataView(sendData.buffer).setUint32(129, 1, false);
|
|
1439
|
+
return sendData;
|
|
1440
|
+
}
|
|
1441
|
+
function buildInit1Step4Packet(crypt, data) {
|
|
1442
|
+
if (data.length !== 233) return null;
|
|
1443
|
+
const answered = answeredChallenges.get(crypt);
|
|
1444
|
+
if (answered !== void 0) return Buffer.from(answered.challenge).equals(Buffer.from(data)) ? answered.answer : null;
|
|
1445
|
+
const level = new DataView(data.buffer, data.byteOffset).getUint32(129, false);
|
|
1446
|
+
const y = crypt.solveRsaChallenge(data, 1, level);
|
|
1447
|
+
const alphaB64 = Buffer.from(crypt.newAlpha()).toString("base64");
|
|
1448
|
+
const omegaB64 = crypt.identity.publicKey();
|
|
1449
|
+
const cmd = buildCommandOrdered("clientinitiv", [
|
|
1450
|
+
["alpha", alphaB64],
|
|
1451
|
+
["omega", omegaB64],
|
|
1452
|
+
["ot", "1"],
|
|
1453
|
+
["ip", ""]
|
|
1454
|
+
]);
|
|
1455
|
+
const cmdBytes = Buffer.from(cmd);
|
|
1456
|
+
const sendData = new Uint8Array(301 + cmdBytes.length);
|
|
1457
|
+
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
1458
|
+
sendData[4] = 4;
|
|
1459
|
+
sendData.set(data.slice(1, 233), 5);
|
|
1460
|
+
sendData.set(y.slice(0, 64), 237);
|
|
1461
|
+
sendData.set(cmdBytes, 301);
|
|
1462
|
+
answeredChallenges.set(crypt, {
|
|
1463
|
+
challenge: Uint8Array.from(data),
|
|
1464
|
+
answer: sendData
|
|
1465
|
+
});
|
|
1466
|
+
return sendData;
|
|
1467
|
+
}
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region src/transport/handler.ts
|
|
1470
|
+
var RECEIVE_PACKET_WINDOW_SIZE = 1024;
|
|
1471
|
+
var PING_INTERVAL_MS = 5e3;
|
|
1472
|
+
var PACKET_TIMEOUT_MS = 6e4;
|
|
1473
|
+
var MAX_RETRY_INTERVAL_MS = 1e3;
|
|
1474
|
+
var TAG_SIZE = 8;
|
|
1475
|
+
var VOICE_HEADER_SIZE = 3;
|
|
1476
|
+
var WHISPER_HEADER_SIZE = 5;
|
|
1477
|
+
var RESEND_BASE_INTERVAL_MS = 500;
|
|
1478
|
+
var RESEND_LOOP_INTERVAL_MS = 100;
|
|
1479
|
+
/**
|
|
1480
|
+
* The largest command reassembled from fragments. The receive window already
|
|
1481
|
+
* bounds how many fragments one command can span; this bounds their total
|
|
1482
|
+
* size, which a datagram's own size limit would otherwise let reach tens of
|
|
1483
|
+
* megabytes.
|
|
1484
|
+
*/
|
|
1485
|
+
var MAX_REASSEMBLED_SIZE = 1 << 20;
|
|
1486
|
+
/**
|
|
1487
|
+
* The packet types the server may send unencrypted: voice, which TeamSpeak
|
|
1488
|
+
* never encrypts, ping and pong, and acknowledgements — TS3 3.13 and TS6 6.0
|
|
1489
|
+
* both acknowledge commands with plaintext Acks once crypto init has
|
|
1490
|
+
* completed. An Ack carries nothing but the id it acknowledges, so a forged
|
|
1491
|
+
* one can at worst stop a resend, which dropping the packet would do too.
|
|
1492
|
+
* Init1 is handled before decryption.
|
|
1493
|
+
*/
|
|
1494
|
+
var UNENCRYPTED_TYPES = /* @__PURE__ */ new Set([
|
|
1495
|
+
PacketType.Voice,
|
|
1496
|
+
PacketType.VoiceWhisper,
|
|
1497
|
+
PacketType.Ping,
|
|
1498
|
+
PacketType.Pong,
|
|
1499
|
+
PacketType.Ack,
|
|
1500
|
+
PacketType.AckLow,
|
|
1501
|
+
PacketType.Init1
|
|
1502
|
+
]);
|
|
1503
|
+
var PacketHandler = class {
|
|
1504
|
+
onPacket = null;
|
|
1505
|
+
onClosed = null;
|
|
1506
|
+
/**
|
|
1507
|
+
* A received datagram could not be handled: it was malformed, or handling it
|
|
1508
|
+
* threw. The datagram is dropped either way; the owner decides whether that
|
|
1509
|
+
* ends the connection. Without an owner the error is logged.
|
|
1510
|
+
*/
|
|
1511
|
+
onReceiveError = null;
|
|
1512
|
+
#crypt;
|
|
1513
|
+
#logger;
|
|
1514
|
+
#socketFactory;
|
|
1515
|
+
#conn = null;
|
|
1516
|
+
#clientID = 0;
|
|
1517
|
+
#closed = false;
|
|
1518
|
+
#stopPing = null;
|
|
1519
|
+
#stopResend = null;
|
|
1520
|
+
#lastMessageReceived = Date.now();
|
|
1521
|
+
#packetCounter = /* @__PURE__ */ new Uint16Array(9);
|
|
1522
|
+
#generationCounter = /* @__PURE__ */ new Uint32Array(9);
|
|
1523
|
+
#recvWindowCommand = new GenerationWindow(65536, RECEIVE_PACKET_WINDOW_SIZE);
|
|
1524
|
+
#recvWindowCommandLow = new GenerationWindow(65536, RECEIVE_PACKET_WINDOW_SIZE);
|
|
1525
|
+
#sendWindowCommand = new GenerationWindow(65536, RECEIVE_PACKET_WINDOW_SIZE);
|
|
1526
|
+
#sendWindowCommandLow = new GenerationWindow(65536, RECEIVE_PACKET_WINDOW_SIZE);
|
|
1527
|
+
#commandQueue = /* @__PURE__ */ new Map();
|
|
1528
|
+
#commandLowQueue = /* @__PURE__ */ new Map();
|
|
1529
|
+
/** The bytes held in both command queues; see MAX_QUEUED_BYTES. */
|
|
1530
|
+
#queuedBytes = 0;
|
|
1531
|
+
#ackManager = /* @__PURE__ */ new Map();
|
|
1532
|
+
#initPacketCheck = null;
|
|
1533
|
+
/**
|
|
1534
|
+
* Command packets sent under the dummy key, before crypto init completed:
|
|
1535
|
+
* clientek, in practice. Both TS3 and TS6 acknowledge clientek under the
|
|
1536
|
+
* dummy key even though it completes crypto init, so the dummy key stays
|
|
1537
|
+
* acceptable for exactly those acknowledgements, and for nothing else.
|
|
1538
|
+
*/
|
|
1539
|
+
#dummyCommandIDs = /* @__PURE__ */ new Set();
|
|
1540
|
+
#resends = 0;
|
|
1541
|
+
constructor(crypt, logger = noopLogger, socketFactory) {
|
|
1542
|
+
this.#crypt = crypt;
|
|
1543
|
+
this.#logger = logger;
|
|
1544
|
+
this.#socketFactory = socketFactory;
|
|
1545
|
+
}
|
|
1546
|
+
setClientID(id) {
|
|
1547
|
+
this.#clientID = id;
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Resolve `addr`, open a socket to it and start the handshake.
|
|
1551
|
+
*
|
|
1552
|
+
* Aborting `signal`, or closing the handler, while this runs abandons it: the
|
|
1553
|
+
* lookup's answer is ignored, and a socket that finishes connecting
|
|
1554
|
+
* afterwards is closed rather than started, so no handshake begins after
|
|
1555
|
+
* the connection was given up.
|
|
1556
|
+
*/
|
|
1557
|
+
async connect(addr, signal) {
|
|
1558
|
+
const { host, portNumber } = parseConnectAddress(addr);
|
|
1559
|
+
this.#throwIfAbandoned(signal);
|
|
1560
|
+
let resolved;
|
|
1561
|
+
try {
|
|
1562
|
+
resolved = await abortable(lookup(host), signal);
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
if (signal?.aborted) throw error;
|
|
1565
|
+
throw new TeamspeakError(`could not resolve ${host}`, { cause: error });
|
|
1566
|
+
}
|
|
1567
|
+
const { address, family } = resolved;
|
|
1568
|
+
this.#throwIfAbandoned(signal);
|
|
1569
|
+
const family6 = family === 6;
|
|
1570
|
+
const socket = this.#socketFactory ? this.#socketFactory(family6 ? 6 : 4) : createSocket(family6 ? "udp6" : "udp4");
|
|
1571
|
+
await new Promise((resolve, reject) => {
|
|
1572
|
+
let done = false;
|
|
1573
|
+
const fail = (error) => {
|
|
1574
|
+
if (done) return;
|
|
1575
|
+
done = true;
|
|
1576
|
+
socket.off("error", onError);
|
|
1577
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1578
|
+
closeQuietly(socket);
|
|
1579
|
+
reject(error);
|
|
1580
|
+
};
|
|
1581
|
+
const onError = (error) => fail(error);
|
|
1582
|
+
const onAbort = () => fail(abortReason(signal));
|
|
1583
|
+
socket.once("error", onError);
|
|
1584
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1585
|
+
socket.connect(portNumber, address, () => {
|
|
1586
|
+
if (done) return;
|
|
1587
|
+
if (this.#closed || signal?.aborted) {
|
|
1588
|
+
fail(signal?.aborted ? abortReason(signal) : new NotConnectedError());
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
done = true;
|
|
1592
|
+
socket.off("error", onError);
|
|
1593
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1594
|
+
this.start(socket);
|
|
1595
|
+
resolve();
|
|
1596
|
+
});
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
#throwIfAbandoned(signal) {
|
|
1600
|
+
signal?.throwIfAborted();
|
|
1601
|
+
if (this.#closed) throw new NotConnectedError();
|
|
1602
|
+
}
|
|
1603
|
+
/** Take over a connected socket and start the handshake on it. A closed handler closes it instead. */
|
|
1604
|
+
start(conn) {
|
|
1605
|
+
if (this.#closed) {
|
|
1606
|
+
closeQuietly(conn);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
this.#conn = conn;
|
|
1610
|
+
this.#lastMessageReceived = Date.now();
|
|
1611
|
+
conn.on("message", (msg) => {
|
|
1612
|
+
if (this.#closed) return;
|
|
1613
|
+
try {
|
|
1614
|
+
this.#handleRawPacket(new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength));
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
if (this.onReceiveError) this.onReceiveError(error);
|
|
1617
|
+
else this.#logger.debug("dropped a malformed packet", { error: String(error) });
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
conn.on("error", (err) => {
|
|
1621
|
+
this.#logger.error("udp error", err);
|
|
1622
|
+
this.#triggerClose(err);
|
|
1623
|
+
});
|
|
1624
|
+
conn.on("close", () => this.#triggerClose(null));
|
|
1625
|
+
this.#packetCounter[PacketType.Command] = 1;
|
|
1626
|
+
this.#packetCounter[PacketType.Init1] = 101;
|
|
1627
|
+
const init1Data = processInit1(this.#crypt, null);
|
|
1628
|
+
if (init1Data) this.#sendPacketRaw(PacketType.Init1, init1Data, 0);
|
|
1629
|
+
const pingTimer = setInterval(() => {
|
|
1630
|
+
if (this.#crypt.cryptoInitComplete) this.sendPacket(PacketType.Ping, /* @__PURE__ */ new Uint8Array(0), PacketFlags.Unencrypted);
|
|
1631
|
+
}, PING_INTERVAL_MS);
|
|
1632
|
+
this.#stopPing = () => clearInterval(pingTimer);
|
|
1633
|
+
const resendTimer = setInterval(() => this.#checkResends(), RESEND_LOOP_INTERVAL_MS);
|
|
1634
|
+
this.#stopResend = () => clearInterval(resendTimer);
|
|
1635
|
+
}
|
|
1636
|
+
receivedFinalInitAck() {
|
|
1637
|
+
this.#initPacketCheck = null;
|
|
1638
|
+
}
|
|
1639
|
+
sendPacket(pType, data, flags) {
|
|
1640
|
+
const dummy = !this.#crypt.cryptoInitComplete;
|
|
1641
|
+
if (data.length > 487 && pType !== PacketType.Voice && pType !== PacketType.VoiceWhisper) {
|
|
1642
|
+
this.#sendSplitPacket(pType, data, flags, dummy);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
this.#sendPacketRaw(pType, data, flags, dummy);
|
|
1646
|
+
}
|
|
1647
|
+
sendVoicePacket(data, codec) {
|
|
1648
|
+
const pID = this.#packetCounter[PacketType.Voice];
|
|
1649
|
+
const pGen = this.#generationCounter[PacketType.Voice];
|
|
1650
|
+
this.#packetCounter[PacketType.Voice] = pID + 1 & 65535;
|
|
1651
|
+
if (this.#packetCounter[PacketType.Voice] === 0) this.#generationCounter[PacketType.Voice]++;
|
|
1652
|
+
const payloadLen = VOICE_HEADER_SIZE + data.length;
|
|
1653
|
+
const voicePayload = new Uint8Array(payloadLen);
|
|
1654
|
+
new DataView(voicePayload.buffer).setUint16(0, pID, false);
|
|
1655
|
+
voicePayload[2] = codec;
|
|
1656
|
+
voicePayload.set(data, VOICE_HEADER_SIZE);
|
|
1657
|
+
const header = buildC2SHeader({
|
|
1658
|
+
typeFlagged: PacketType.Voice | PacketFlags.Unencrypted,
|
|
1659
|
+
id: pID,
|
|
1660
|
+
clientID: this.#clientID,
|
|
1661
|
+
generationID: pGen,
|
|
1662
|
+
data: voicePayload,
|
|
1663
|
+
receivedAt: 0
|
|
1664
|
+
});
|
|
1665
|
+
const final = new Uint8Array(13 + payloadLen);
|
|
1666
|
+
final.set(this.#crypt.fakeSignature, 0);
|
|
1667
|
+
final.set(header, TAG_SIZE);
|
|
1668
|
+
final.set(voicePayload, 13);
|
|
1669
|
+
this.#write(final);
|
|
1670
|
+
}
|
|
1671
|
+
/** Send an unencrypted TeamSpeak voice whisper packet to up to 32 clients. */
|
|
1672
|
+
sendWhisperPacket(data, targetClientIds, codec) {
|
|
1673
|
+
if (targetClientIds.length === 0) throw new RangeError("a whisper needs at least one target");
|
|
1674
|
+
if (targetClientIds.length > 32) throw new RangeError("a whisper has at most 32 targets");
|
|
1675
|
+
if (targetClientIds.some((id) => !Number.isInteger(id) || id < 0 || id > 65535)) throw new RangeError("invalid whisper target");
|
|
1676
|
+
const pID = this.#packetCounter[PacketType.VoiceWhisper];
|
|
1677
|
+
const pGen = this.#generationCounter[PacketType.VoiceWhisper];
|
|
1678
|
+
this.#packetCounter[PacketType.VoiceWhisper] = pID + 1 & 65535;
|
|
1679
|
+
if (this.#packetCounter[PacketType.VoiceWhisper] === 0) this.#generationCounter[PacketType.VoiceWhisper] = pGen + 1 >>> 0;
|
|
1680
|
+
const payloadLen = WHISPER_HEADER_SIZE + targetClientIds.length * 2 + data.length;
|
|
1681
|
+
const whisperPayload = new Uint8Array(payloadLen);
|
|
1682
|
+
const view = new DataView(whisperPayload.buffer);
|
|
1683
|
+
view.setUint16(0, pID, false);
|
|
1684
|
+
whisperPayload[2] = codec;
|
|
1685
|
+
whisperPayload[3] = 0;
|
|
1686
|
+
whisperPayload[4] = targetClientIds.length;
|
|
1687
|
+
for (let index = 0; index < targetClientIds.length; index++) view.setUint16(WHISPER_HEADER_SIZE + index * 2, targetClientIds[index], false);
|
|
1688
|
+
whisperPayload.set(data, WHISPER_HEADER_SIZE + targetClientIds.length * 2);
|
|
1689
|
+
const header = buildC2SHeader({
|
|
1690
|
+
typeFlagged: PacketType.VoiceWhisper | PacketFlags.Unencrypted,
|
|
1691
|
+
id: pID,
|
|
1692
|
+
clientID: this.#clientID,
|
|
1693
|
+
generationID: pGen,
|
|
1694
|
+
data: whisperPayload,
|
|
1695
|
+
receivedAt: 0
|
|
1696
|
+
});
|
|
1697
|
+
const final = new Uint8Array(13 + payloadLen);
|
|
1698
|
+
final.set(this.#crypt.fakeSignature, 0);
|
|
1699
|
+
final.set(header, TAG_SIZE);
|
|
1700
|
+
final.set(whisperPayload, 13);
|
|
1701
|
+
this.#write(final);
|
|
1702
|
+
}
|
|
1703
|
+
/** How many packets have been sent again for want of an acknowledgement. */
|
|
1704
|
+
get resends() {
|
|
1705
|
+
return this.#resends;
|
|
1706
|
+
}
|
|
1707
|
+
/** Whether there is a live socket to send on. */
|
|
1708
|
+
get isOpen() {
|
|
1709
|
+
return this.#conn !== null && !this.#closed;
|
|
1710
|
+
}
|
|
1711
|
+
close() {
|
|
1712
|
+
if (this.#closed) return;
|
|
1713
|
+
this.#closed = true;
|
|
1714
|
+
this.#stopPing?.();
|
|
1715
|
+
this.#stopResend?.();
|
|
1716
|
+
const conn = this.#conn;
|
|
1717
|
+
this.#conn = null;
|
|
1718
|
+
if (conn === null) return;
|
|
1719
|
+
try {
|
|
1720
|
+
conn.close();
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
this.#logger.debug("socket already closed", { error: String(error) });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
#sendSplitPacket(pType, data, flags, dummy) {
|
|
1726
|
+
const maxSize = 487;
|
|
1727
|
+
let pos = 0;
|
|
1728
|
+
let first = true;
|
|
1729
|
+
while (pos < data.length) {
|
|
1730
|
+
const blockSize = Math.min(data.length - pos, maxSize);
|
|
1731
|
+
const last = pos + blockSize === data.length;
|
|
1732
|
+
let pFlags = flags;
|
|
1733
|
+
if (first !== last) pFlags |= PacketFlags.Fragmented;
|
|
1734
|
+
this.#sendPacketRaw(pType, data.slice(pos, pos + blockSize), pFlags, dummy);
|
|
1735
|
+
pos += blockSize;
|
|
1736
|
+
first = false;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
#sendPacketRaw(pType, data, flags, dummy = !this.#crypt.cryptoInitComplete) {
|
|
1740
|
+
flags = applyProtocolFlags(pType, flags);
|
|
1741
|
+
const [pID, pGen] = this.#nextPacketIdentity(pType);
|
|
1742
|
+
const p = {
|
|
1743
|
+
typeFlagged: pType | flags,
|
|
1744
|
+
id: pID,
|
|
1745
|
+
clientID: this.#clientID,
|
|
1746
|
+
generationID: pGen,
|
|
1747
|
+
data,
|
|
1748
|
+
receivedAt: 0
|
|
1749
|
+
};
|
|
1750
|
+
const unencrypted = (flags & PacketFlags.Unencrypted) !== 0;
|
|
1751
|
+
const header = buildC2SHeader(p);
|
|
1752
|
+
const [ciphertext, tag] = this.#crypt.encrypt(pType, pID, pGen, header, data, dummy, unencrypted);
|
|
1753
|
+
const final = new Uint8Array(13 + ciphertext.length);
|
|
1754
|
+
final.set(tag.slice(0, TAG_SIZE), 0);
|
|
1755
|
+
final.set(header, TAG_SIZE);
|
|
1756
|
+
final.set(ciphertext, 13);
|
|
1757
|
+
this.#write(final);
|
|
1758
|
+
if (dummy && pType === PacketType.Command) this.#dummyCommandIDs.add(pID);
|
|
1759
|
+
const rp = {
|
|
1760
|
+
packet: p,
|
|
1761
|
+
firstSend: Date.now(),
|
|
1762
|
+
lastSend: Date.now(),
|
|
1763
|
+
retryCount: 0,
|
|
1764
|
+
nextInterval: RESEND_BASE_INTERVAL_MS
|
|
1765
|
+
};
|
|
1766
|
+
this.#trackResend(pType, p, rp);
|
|
1767
|
+
}
|
|
1768
|
+
#write(data) {
|
|
1769
|
+
if (!this.#conn) return;
|
|
1770
|
+
try {
|
|
1771
|
+
this.#conn.send(data, (err) => {
|
|
1772
|
+
if (err) this.#logger.warn("udp send error", err);
|
|
1773
|
+
});
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1776
|
+
this.#logger.error("udp send failed", normalized);
|
|
1777
|
+
this.#triggerClose(normalized);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
#handleRawPacket(raw) {
|
|
1781
|
+
if (raw.length < 11) return;
|
|
1782
|
+
if (raw.length > 4096) {
|
|
1783
|
+
this.#logger.debug("dropped an oversized datagram", { size: raw.length });
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const tag = raw.slice(0, TAG_SIZE);
|
|
1787
|
+
const header = raw.slice(TAG_SIZE, 11);
|
|
1788
|
+
const ciphertext = raw.slice(11);
|
|
1789
|
+
const parsed = parseS2CHeader(header);
|
|
1790
|
+
if ((parsed.typeFlagged & 15) === PacketType.Init1 && this.#crypt.cryptoInitComplete) return;
|
|
1791
|
+
const p = {
|
|
1792
|
+
...parsed,
|
|
1793
|
+
clientID: 0,
|
|
1794
|
+
generationID: this.#resolveGeneration(parsed.id, parsed.typeFlagged & 15),
|
|
1795
|
+
data: /* @__PURE__ */ new Uint8Array(0),
|
|
1796
|
+
receivedAt: Date.now()
|
|
1797
|
+
};
|
|
1798
|
+
const decrypted = this.#decryptPacketData(p, header, ciphertext, tag);
|
|
1799
|
+
if (decrypted === null) return;
|
|
1800
|
+
p.data = decrypted.plaintext;
|
|
1801
|
+
this.#lastMessageReceived = Date.now();
|
|
1802
|
+
if (packetType(p) === PacketType.Ping) {
|
|
1803
|
+
this.#sendPong(p.id, decrypted.dummyUsed);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
if (!this.#handleCommandWindowAndAck(p, decrypted.dummyUsed)) return;
|
|
1807
|
+
this.#handlePacketQueue(p);
|
|
1808
|
+
this.#updatePostReceiveState(p);
|
|
1809
|
+
}
|
|
1810
|
+
#resolveGeneration(id, pType) {
|
|
1811
|
+
switch (pType) {
|
|
1812
|
+
case PacketType.Command: return this.#recvWindowCommand.getGeneration(id);
|
|
1813
|
+
case PacketType.CommandLow: return this.#recvWindowCommandLow.getGeneration(id);
|
|
1814
|
+
case PacketType.Ack: return this.#sendWindowCommand.getGeneration(id);
|
|
1815
|
+
case PacketType.AckLow: return this.#sendWindowCommandLow.getGeneration(id);
|
|
1816
|
+
default: return 0;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
#decryptPacketData(p, header, ciphertext, tag) {
|
|
1820
|
+
const unencrypted = (packetFlags(p) & PacketFlags.Unencrypted) !== 0;
|
|
1821
|
+
const dummy = !this.#crypt.cryptoInitComplete;
|
|
1822
|
+
let dummyUsed = dummy;
|
|
1823
|
+
const pType = packetType(p);
|
|
1824
|
+
const gen = p.generationID;
|
|
1825
|
+
if (unencrypted && !UNENCRYPTED_TYPES.has(pType)) {
|
|
1826
|
+
this.#logger.debug("dropped an unencrypted packet of a type that must be encrypted", {
|
|
1827
|
+
type: pType,
|
|
1828
|
+
id: p.id
|
|
1829
|
+
});
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
try {
|
|
1833
|
+
return {
|
|
1834
|
+
plaintext: this.#crypt.decrypt(pType, p.id, gen, header, ciphertext, tag, dummy, unencrypted),
|
|
1835
|
+
dummyUsed
|
|
1836
|
+
};
|
|
1837
|
+
} catch {
|
|
1838
|
+
if (dummy) return null;
|
|
1839
|
+
if (!dummy && pType === PacketType.Ack && !unencrypted) try {
|
|
1840
|
+
const plaintext = this.#crypt.decrypt(pType, p.id, gen, header, ciphertext, tag, true, false);
|
|
1841
|
+
const acked = plaintext.length >= 2 ? plaintext[0] << 8 | plaintext[1] : NaN;
|
|
1842
|
+
if (this.#dummyCommandIDs.delete(acked)) return {
|
|
1843
|
+
plaintext,
|
|
1844
|
+
dummyUsed: true
|
|
1845
|
+
};
|
|
1846
|
+
} catch {}
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
#handleCommandWindowAndAck(p, dummyUsed) {
|
|
1851
|
+
const pType = packetType(p);
|
|
1852
|
+
if (pType !== PacketType.Command && pType !== PacketType.CommandLow) return true;
|
|
1853
|
+
const win = pType === PacketType.Command ? this.#recvWindowCommand : this.#recvWindowCommandLow;
|
|
1854
|
+
const ackType = pType === PacketType.Command ? PacketType.Ack : PacketType.AckLow;
|
|
1855
|
+
if (!win.isInWindow(p.id)) {
|
|
1856
|
+
if (win.isOldPacket(p.id)) this.#sendAck(p.id, ackType, dummyUsed);
|
|
1857
|
+
return false;
|
|
1858
|
+
}
|
|
1859
|
+
const held = (pType === PacketType.Command ? this.#commandQueue : this.#commandLowQueue).get(p.id)?.data.length ?? 0;
|
|
1860
|
+
const next = pType === PacketType.Command ? this.#_nextCommandID : this.#_nextCommandLowID;
|
|
1861
|
+
if (p.id !== next && this.#queuedBytes - held + p.data.length > 1048576) {
|
|
1862
|
+
this.#logger.debug("dropped a command packet: the receive queues are full", { id: p.id });
|
|
1863
|
+
return false;
|
|
1864
|
+
}
|
|
1865
|
+
this.#sendAck(p.id, ackType, dummyUsed);
|
|
1866
|
+
return true;
|
|
1867
|
+
}
|
|
1868
|
+
#sendAck(packetID, ackType, dummyUsed) {
|
|
1869
|
+
const ackData = /* @__PURE__ */ new Uint8Array(2);
|
|
1870
|
+
new DataView(ackData.buffer).setUint16(0, packetID, false);
|
|
1871
|
+
this.#sendPacketRaw(ackType, ackData, 0, dummyUsed);
|
|
1872
|
+
}
|
|
1873
|
+
#sendPong(pID, dummyUsed) {
|
|
1874
|
+
const pongData = /* @__PURE__ */ new Uint8Array(2);
|
|
1875
|
+
new DataView(pongData.buffer).setUint16(0, pID, false);
|
|
1876
|
+
this.#sendPacketRaw(PacketType.Pong, pongData, PacketFlags.Unencrypted, dummyUsed);
|
|
1877
|
+
}
|
|
1878
|
+
#handlePacketQueue(p) {
|
|
1879
|
+
const pType = packetType(p);
|
|
1880
|
+
if (pType !== PacketType.Command && pType !== PacketType.CommandLow) {
|
|
1881
|
+
this.onPacket?.(p);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
const isCommand = pType === PacketType.Command;
|
|
1885
|
+
const queue = isCommand ? this.#commandQueue : this.#commandLowQueue;
|
|
1886
|
+
const win = isCommand ? this.#recvWindowCommand : this.#recvWindowCommandLow;
|
|
1887
|
+
this.#hold(queue, p);
|
|
1888
|
+
if (isCommand) this.#fastForwardMissingPackets(queue, win, true);
|
|
1889
|
+
else this.#fastForwardMissingPackets(queue, win, false);
|
|
1890
|
+
while (true) {
|
|
1891
|
+
const current = isCommand ? this.#_nextCommandID : this.#_nextCommandLowID;
|
|
1892
|
+
const packet = queue.get(current);
|
|
1893
|
+
if (packet === void 0) break;
|
|
1894
|
+
const result = this.#tryReassemble(packet, queue, current, win);
|
|
1895
|
+
if (!result) break;
|
|
1896
|
+
const [reassembled, newNext] = result;
|
|
1897
|
+
if (isCommand) this.#_nextCommandID = newNext;
|
|
1898
|
+
else this.#_nextCommandLowID = newNext;
|
|
1899
|
+
if (reassembled === null) {
|
|
1900
|
+
this.#logger.debug("dropped a command over the reassembly limit", { id: current });
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
if (!this.#tryDecompress(reassembled)) continue;
|
|
1904
|
+
this.onPacket?.(reassembled);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/** Put a packet in its queue, counting its bytes; one with the same id is replaced. */
|
|
1908
|
+
#hold(queue, p) {
|
|
1909
|
+
this.#release(queue, p.id);
|
|
1910
|
+
queue.set(p.id, p);
|
|
1911
|
+
this.#queuedBytes += p.data.length;
|
|
1912
|
+
}
|
|
1913
|
+
#release(queue, id) {
|
|
1914
|
+
const held = queue.get(id);
|
|
1915
|
+
if (held === void 0) return;
|
|
1916
|
+
queue.delete(id);
|
|
1917
|
+
this.#queuedBytes -= held.data.length;
|
|
1918
|
+
}
|
|
1919
|
+
#_nextCommandID = 0;
|
|
1920
|
+
#_nextCommandLowID = 0;
|
|
1921
|
+
#fastForwardMissingPackets(queue, win, isCommand) {
|
|
1922
|
+
let nextID = isCommand ? this.#_nextCommandID : this.#_nextCommandLowID;
|
|
1923
|
+
while (!queue.has(nextID) && hasOldNewerPacket(queue, nextID)) {
|
|
1924
|
+
nextID = nextID + 1 & 65535;
|
|
1925
|
+
win.advance(1);
|
|
1926
|
+
}
|
|
1927
|
+
if (isCommand) this.#_nextCommandID = nextID;
|
|
1928
|
+
else this.#_nextCommandLowID = nextID;
|
|
1929
|
+
}
|
|
1930
|
+
#tryReassemble(startPacket, queue, nextID, win) {
|
|
1931
|
+
if ((packetFlags(startPacket) & PacketFlags.Fragmented) === 0) {
|
|
1932
|
+
this.#release(queue, nextID);
|
|
1933
|
+
win.advance(1);
|
|
1934
|
+
return [startPacket, nextID + 1 & 65535];
|
|
1935
|
+
}
|
|
1936
|
+
const fragments = [];
|
|
1937
|
+
let totalSize = 0;
|
|
1938
|
+
let currID = nextID;
|
|
1939
|
+
let startSeen = false;
|
|
1940
|
+
let complete = false;
|
|
1941
|
+
while (true) {
|
|
1942
|
+
const frag = queue.get(currID);
|
|
1943
|
+
if (!frag) return null;
|
|
1944
|
+
fragments.push(frag);
|
|
1945
|
+
totalSize += frag.data.length;
|
|
1946
|
+
if (totalSize > MAX_REASSEMBLED_SIZE) {
|
|
1947
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
1948
|
+
this.#release(queue, nextID);
|
|
1949
|
+
win.advance(1);
|
|
1950
|
+
nextID = nextID + 1 & 65535;
|
|
1951
|
+
}
|
|
1952
|
+
return [null, nextID];
|
|
1953
|
+
}
|
|
1954
|
+
const fragmented = (packetFlags(frag) & PacketFlags.Fragmented) !== 0;
|
|
1955
|
+
if (!startSeen) {
|
|
1956
|
+
startSeen = true;
|
|
1957
|
+
if (!fragmented) {
|
|
1958
|
+
complete = true;
|
|
1959
|
+
break;
|
|
1960
|
+
}
|
|
1961
|
+
} else if (fragmented) {
|
|
1962
|
+
complete = true;
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
currID = currID + 1 & 65535;
|
|
1966
|
+
}
|
|
1967
|
+
if (!complete) return null;
|
|
1968
|
+
const combined = new Uint8Array(totalSize);
|
|
1969
|
+
let pos = 0;
|
|
1970
|
+
for (const frag of fragments) {
|
|
1971
|
+
combined.set(frag.data, pos);
|
|
1972
|
+
pos += frag.data.length;
|
|
1973
|
+
this.#release(queue, nextID);
|
|
1974
|
+
win.advance(1);
|
|
1975
|
+
nextID = nextID + 1 & 65535;
|
|
1976
|
+
}
|
|
1977
|
+
startPacket.data = combined;
|
|
1978
|
+
startPacket.typeFlagged &= ~PacketFlags.Fragmented;
|
|
1979
|
+
return [startPacket, nextID];
|
|
1980
|
+
}
|
|
1981
|
+
/** Decompress a command in place; false when it cannot be, and is to be dropped. */
|
|
1982
|
+
#tryDecompress(packet) {
|
|
1983
|
+
if ((packetFlags(packet) & PacketFlags.Compressed) === 0) return true;
|
|
1984
|
+
try {
|
|
1985
|
+
packet.data = new Qlz().decompress(packet.data);
|
|
1986
|
+
packet.typeFlagged &= ~PacketFlags.Compressed;
|
|
1987
|
+
return true;
|
|
1988
|
+
} catch (err) {
|
|
1989
|
+
this.#logger.debug("dropped a command that failed to decompress", {
|
|
1990
|
+
id: packet.id,
|
|
1991
|
+
error: String(err)
|
|
1992
|
+
});
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
#updatePostReceiveState(p) {
|
|
1997
|
+
const pType = packetType(p);
|
|
1998
|
+
if (pType === PacketType.Init1) {
|
|
1999
|
+
this.#initPacketCheck = null;
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
if ((pType === PacketType.Ack || pType === PacketType.AckLow) && p.data.length >= 2) {
|
|
2003
|
+
const ackID = new DataView(p.data.buffer, p.data.byteOffset).getUint16(0, false);
|
|
2004
|
+
const key = (pType === PacketType.Ack ? PacketType.Command : PacketType.CommandLow) << 16 | ackID;
|
|
2005
|
+
this.#ackManager.delete(key);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
#nextPacketIdentity(pType) {
|
|
2009
|
+
const pID = this.#packetCounter[pType];
|
|
2010
|
+
const pGen = this.#generationCounter[pType];
|
|
2011
|
+
if (pType === PacketType.Init1) return [pID, pGen];
|
|
2012
|
+
this.#packetCounter[pType] = pID + 1 & 65535;
|
|
2013
|
+
if (this.#packetCounter[pType] === 0) this.#generationCounter[pType] = pGen + 1 >>> 0;
|
|
2014
|
+
if (pType === PacketType.Command) this.#sendWindowCommand.advanceToExcluded(pID);
|
|
2015
|
+
else if (pType === PacketType.CommandLow) this.#sendWindowCommandLow.advanceToExcluded(pID);
|
|
2016
|
+
return [pID, pGen];
|
|
2017
|
+
}
|
|
2018
|
+
#trackResend(pType, p, rp) {
|
|
2019
|
+
if (pType === PacketType.Init1) {
|
|
2020
|
+
this.#initPacketCheck = rp;
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (pType === PacketType.Command || pType === PacketType.CommandLow) {
|
|
2024
|
+
const key = pType << 16 | p.id;
|
|
2025
|
+
this.#ackManager.set(key, rp);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
#checkResends() {
|
|
2029
|
+
const now = Date.now();
|
|
2030
|
+
if (now - this.#lastMessageReceived > PACKET_TIMEOUT_MS) {
|
|
2031
|
+
this.#logger.warn("idle timeout");
|
|
2032
|
+
this.#triggerClose(new ConnectionTimeoutError());
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (this.#initPacketCheck) this.#doResend(this.#initPacketCheck, now);
|
|
2036
|
+
for (const [key, rp] of this.#ackManager) {
|
|
2037
|
+
if (now - rp.firstSend > PACKET_TIMEOUT_MS) {
|
|
2038
|
+
this.#ackManager.delete(key);
|
|
2039
|
+
this.#triggerClose(new ConnectionTimeoutError());
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
this.#doResend(rp, now);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
#doResend(rp, now) {
|
|
2046
|
+
if (now - rp.lastSend < rp.nextInterval) return;
|
|
2047
|
+
rp.lastSend = now;
|
|
2048
|
+
rp.retryCount++;
|
|
2049
|
+
this.#resends++;
|
|
2050
|
+
rp.nextInterval = Math.min(rp.nextInterval * 2, MAX_RETRY_INTERVAL_MS);
|
|
2051
|
+
const dummy = !this.#crypt.cryptoInitComplete;
|
|
2052
|
+
const unencrypted = (packetFlags(rp.packet) & PacketFlags.Unencrypted) !== 0;
|
|
2053
|
+
const header = buildC2SHeader(rp.packet);
|
|
2054
|
+
const pType = packetType(rp.packet);
|
|
2055
|
+
const [ciphertext, tag] = this.#crypt.encrypt(pType, rp.packet.id, rp.packet.generationID, header, rp.packet.data, dummy, unencrypted);
|
|
2056
|
+
const final = new Uint8Array(13 + ciphertext.length);
|
|
2057
|
+
final.set(tag.slice(0, TAG_SIZE), 0);
|
|
2058
|
+
final.set(header, TAG_SIZE);
|
|
2059
|
+
final.set(ciphertext, 13);
|
|
2060
|
+
this.#write(final);
|
|
2061
|
+
}
|
|
2062
|
+
#triggerClose(err) {
|
|
2063
|
+
if (this.#closed) return;
|
|
2064
|
+
this.close();
|
|
2065
|
+
this.onClosed?.(err);
|
|
2066
|
+
}
|
|
2067
|
+
};
|
|
2068
|
+
/** Close a socket that may already be closed; dgram throws on a second close. */
|
|
2069
|
+
function closeQuietly(socket) {
|
|
2070
|
+
try {
|
|
2071
|
+
socket.close();
|
|
2072
|
+
} catch {}
|
|
2073
|
+
}
|
|
2074
|
+
function applyProtocolFlags(pType, flags) {
|
|
2075
|
+
if (pType === PacketType.Command || pType === PacketType.CommandLow) return flags | PacketFlags.NewProtocol;
|
|
2076
|
+
return flags;
|
|
2077
|
+
}
|
|
2078
|
+
function hasOldNewerPacket(queue, nextID) {
|
|
2079
|
+
const now = Date.now();
|
|
2080
|
+
for (const [id, pkg] of queue) if ((id - nextID + 65536 & 65535) < 32768 && now - pkg.receivedAt > 5e3) return true;
|
|
2081
|
+
return false;
|
|
2082
|
+
}
|
|
2083
|
+
//#endregion
|
|
2084
|
+
//#region src/commands.ts
|
|
2085
|
+
/**
|
|
2086
|
+
* Matches the server's answers to the commands that asked for them.
|
|
2087
|
+
*
|
|
2088
|
+
* The server handles one client's commands in order and closes each answer
|
|
2089
|
+
* with `error … return_code=N`, so every row seen since the previous `error`
|
|
2090
|
+
* belongs to command N. Rows are therefore held, tagged with where they came
|
|
2091
|
+
* from, until that `error` says whose they are — attributing them on arrival
|
|
2092
|
+
* would mean guessing which in-flight command the server is answering, and a
|
|
2093
|
+
* command can be registered here well before it is sent (it first waits on the
|
|
2094
|
+
* throttle and the middleware chain).
|
|
2095
|
+
*
|
|
2096
|
+
* A nameless row always belongs to the command. A notification row belongs to
|
|
2097
|
+
* it only if the command declared that notification as its answer (see
|
|
2098
|
+
* command/responses.ts): the same `notifyclientmoved` that answers nothing can
|
|
2099
|
+
* arrive between any two commands.
|
|
2100
|
+
*
|
|
2101
|
+
* The welcome sequence is the exception. The server pushes channellist,
|
|
2102
|
+
* channelgrouplist and the rest unprompted right after the handshake, before
|
|
2103
|
+
* `channellistfinished`; without the gate they would land in the first
|
|
2104
|
+
* command's answer as duplicate rows and a phantom cid=0.
|
|
2105
|
+
*/
|
|
2106
|
+
var CommandTracker = class {
|
|
2107
|
+
#pending = /* @__PURE__ */ new Map();
|
|
2108
|
+
#ownClientID;
|
|
2109
|
+
#nextRC = 0;
|
|
2110
|
+
#buffer = [];
|
|
2111
|
+
#welcomeComplete = false;
|
|
2112
|
+
/** @param ownClientID the client's clid, for answers filtered by invoker. */
|
|
2113
|
+
constructor(ownClientID = () => 0) {
|
|
2114
|
+
this.#ownClientID = ownClientID;
|
|
2115
|
+
}
|
|
2116
|
+
register(spec, correlation) {
|
|
2117
|
+
this.#nextRC++;
|
|
2118
|
+
const rc = this.#nextRC;
|
|
2119
|
+
return [rc, new Promise((resolve) => {
|
|
2120
|
+
this.#pending.set(rc, {
|
|
2121
|
+
resolve,
|
|
2122
|
+
spec,
|
|
2123
|
+
correlation,
|
|
2124
|
+
correlated: []
|
|
2125
|
+
});
|
|
2126
|
+
})];
|
|
2127
|
+
}
|
|
2128
|
+
unregister(rc) {
|
|
2129
|
+
this.#pending.delete(rc);
|
|
2130
|
+
}
|
|
2131
|
+
/** Marks the end of the welcome sequence and drops what it left behind. */
|
|
2132
|
+
signalWelcomeComplete() {
|
|
2133
|
+
this.#welcomeComplete = true;
|
|
2134
|
+
this.#buffer = [];
|
|
2135
|
+
}
|
|
2136
|
+
/** Whether some in-flight command declared `name` as its answer. */
|
|
2137
|
+
expects(name) {
|
|
2138
|
+
for (const p of this.#pending.values()) {
|
|
2139
|
+
if (p.spec?.notifications?.includes(name)) return true;
|
|
2140
|
+
if (p.spec?.correlated?.notifications.includes(name)) return true;
|
|
2141
|
+
}
|
|
2142
|
+
return false;
|
|
2143
|
+
}
|
|
2144
|
+
/** Hold a row until the next `error` says which command it answered. */
|
|
2145
|
+
buffer(params, source = "") {
|
|
2146
|
+
if (!this.#welcomeComplete) return;
|
|
2147
|
+
if (this.#pending.size === 0) return;
|
|
2148
|
+
this.#buffer.push({
|
|
2149
|
+
source,
|
|
2150
|
+
params
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Offer a notification to commands answered after their `error`.
|
|
2155
|
+
*
|
|
2156
|
+
* Matching is on the echoed request field, so it does not matter whether the
|
|
2157
|
+
* notification beats the `error` or follows it.
|
|
2158
|
+
*/
|
|
2159
|
+
offerCorrelated(name, params) {
|
|
2160
|
+
for (const [rc, p] of this.#pending) {
|
|
2161
|
+
const c = p.spec?.correlated;
|
|
2162
|
+
if (!c || p.correlation === void 0 || !c.notifications.includes(name)) continue;
|
|
2163
|
+
if (params[c.key] !== p.correlation) continue;
|
|
2164
|
+
if (name === c.failure && (params["status"] ?? "0") !== "0") {
|
|
2165
|
+
const err = new ServerError(parseErrorCode(params["status"]), params["msg"] ?? "", { extraMessage: params["extra_msg"] || void 0 });
|
|
2166
|
+
this.#settle(rc, p, {
|
|
2167
|
+
err,
|
|
2168
|
+
data: []
|
|
2169
|
+
});
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
p.correlated.push(params);
|
|
2173
|
+
if (p.settled) this.#settle(rc, p, {
|
|
2174
|
+
err: null,
|
|
2175
|
+
data: [...p.settled.data, ...p.correlated]
|
|
2176
|
+
});
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
resolve(rc, err) {
|
|
2181
|
+
const rows = this.#buffer;
|
|
2182
|
+
this.#buffer = [];
|
|
2183
|
+
const p = this.#pending.get(rc);
|
|
2184
|
+
if (!p) return;
|
|
2185
|
+
const data = rows.filter((row) => this.#answers(p, row)).map((row) => row.params);
|
|
2186
|
+
if (err) {
|
|
2187
|
+
this.#settle(rc, p, {
|
|
2188
|
+
err,
|
|
2189
|
+
data
|
|
2190
|
+
});
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (p.spec?.correlated && p.correlation !== void 0 && p.correlated.length === 0) {
|
|
2194
|
+
p.settled = {
|
|
2195
|
+
err: null,
|
|
2196
|
+
data
|
|
2197
|
+
};
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
this.#settle(rc, p, {
|
|
2201
|
+
err: null,
|
|
2202
|
+
data: [...data, ...p.correlated]
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
discardBuffer() {
|
|
2206
|
+
this.#buffer = [];
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Forget every command and fail it with `err`.
|
|
2210
|
+
*
|
|
2211
|
+
* Dropping them silently would leave each caller waiting out its full
|
|
2212
|
+
* timeout for an answer that can no longer come.
|
|
2213
|
+
*/
|
|
2214
|
+
reset(err) {
|
|
2215
|
+
const pending = [...this.#pending.values()];
|
|
2216
|
+
this.#pending.clear();
|
|
2217
|
+
for (const p of pending) p.resolve({
|
|
2218
|
+
err,
|
|
2219
|
+
data: []
|
|
2220
|
+
});
|
|
2221
|
+
this.#buffer = [];
|
|
2222
|
+
this.#welcomeComplete = false;
|
|
2223
|
+
this.#nextRC = 0;
|
|
2224
|
+
}
|
|
2225
|
+
#answers(p, row) {
|
|
2226
|
+
if (row.source === "") return true;
|
|
2227
|
+
if (!p.spec?.notifications?.includes(row.source)) return false;
|
|
2228
|
+
if (!p.spec.ownRowsOnly) return true;
|
|
2229
|
+
return row.params["invokerid"] === String(this.#ownClientID());
|
|
2230
|
+
}
|
|
2231
|
+
#settle(rc, p, result) {
|
|
2232
|
+
this.#pending.delete(rc);
|
|
2233
|
+
p.resolve(result);
|
|
2234
|
+
}
|
|
2235
|
+
};
|
|
2236
|
+
/**
|
|
2237
|
+
* Append the tracker's return_code to a command.
|
|
2238
|
+
*
|
|
2239
|
+
* The return_code belongs to the tracker: it is how an answer finds its
|
|
2240
|
+
* command, so a caller-supplied one would detach the command from its answer.
|
|
2241
|
+
* Checked as a parameter rather than as text, because a message body that
|
|
2242
|
+
* merely contains "return_code=" used to suppress the real one and time the
|
|
2243
|
+
* command out.
|
|
2244
|
+
*/
|
|
2245
|
+
function appendReturnCode(cmd, rc) {
|
|
2246
|
+
const params = parseCommand(cmd)?.params ?? {};
|
|
2247
|
+
if (Object.hasOwn(params, "return_code")) throw new TypeError("commands must not carry their own return_code; the client assigns it");
|
|
2248
|
+
return `${cmd} return_code=${rc}`;
|
|
2249
|
+
}
|
|
2250
|
+
//#endregion
|
|
2251
|
+
//#region src/transfer.ts
|
|
2252
|
+
/**
|
|
2253
|
+
* File transfer data.
|
|
2254
|
+
*
|
|
2255
|
+
* Negotiating a transfer is a command (`initFileUpload`, `initFileDownload`);
|
|
2256
|
+
* the bytes then go over a separate TCP connection to the server's file
|
|
2257
|
+
* transfer port, opened with the key the negotiation returned.
|
|
2258
|
+
*/
|
|
2259
|
+
/** How long dialing may take before it fails, unless a signal says otherwise. */
|
|
2260
|
+
var DIAL_TIMEOUT_MS = 1e4;
|
|
2261
|
+
/**
|
|
2262
|
+
* Open a connection to the file transfer port and send the transfer key.
|
|
2263
|
+
* The caller owns the returned stream and must end or destroy it.
|
|
2264
|
+
*/
|
|
2265
|
+
async function dialFileTransfer(host, port, key, options = {}) {
|
|
2266
|
+
const { signal, socketFactory } = options;
|
|
2267
|
+
signal?.throwIfAborted();
|
|
2268
|
+
const socket = socketFactory ? socketFactory(host, port) : connect({
|
|
2269
|
+
host,
|
|
2270
|
+
port
|
|
2271
|
+
});
|
|
2272
|
+
const swallow = () => {};
|
|
2273
|
+
socket.on("error", swallow);
|
|
2274
|
+
try {
|
|
2275
|
+
await connected(socket, signal);
|
|
2276
|
+
await new Promise((resolve, reject) => {
|
|
2277
|
+
socket.write(key, (err) => err ? reject(new FileTransferError(`failed to send transfer key: ${err.message}`, { cause: err })) : resolve());
|
|
2278
|
+
});
|
|
2279
|
+
} catch (error) {
|
|
2280
|
+
socket.destroy();
|
|
2281
|
+
throw error;
|
|
2282
|
+
} finally {
|
|
2283
|
+
socket.off("error", swallow);
|
|
2284
|
+
}
|
|
2285
|
+
return socket;
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Wait for a stream to be connected. A `node:net` socket says so with
|
|
2289
|
+
* `connect`; a stream from elsewhere that is already writable is taken as
|
|
2290
|
+
* connected.
|
|
2291
|
+
*/
|
|
2292
|
+
function connected(socket, signal) {
|
|
2293
|
+
const pending = socket.pending;
|
|
2294
|
+
if (pending === false || pending === void 0 && socket.writable) return Promise.resolve();
|
|
2295
|
+
return new Promise((resolve, reject) => {
|
|
2296
|
+
const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
|
|
2297
|
+
const done = (error) => {
|
|
2298
|
+
socket.off("connect", onConnect);
|
|
2299
|
+
socket.off("ready", onConnect);
|
|
2300
|
+
socket.off("error", onError);
|
|
2301
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2302
|
+
timeout.removeEventListener("abort", onTimeout);
|
|
2303
|
+
if (error === void 0) resolve();
|
|
2304
|
+
else reject(error);
|
|
2305
|
+
};
|
|
2306
|
+
const onConnect = () => done();
|
|
2307
|
+
const onError = (error) => done(new FileTransferError(`file transfer connection failed: ${error.message}`, { cause: error }));
|
|
2308
|
+
const onAbort = () => done(signal.reason);
|
|
2309
|
+
const onTimeout = () => done(new FileTransferError("file transfer connection timed out"));
|
|
2310
|
+
socket.once("connect", onConnect);
|
|
2311
|
+
socket.once("ready", onConnect);
|
|
2312
|
+
socket.once("error", onError);
|
|
2313
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2314
|
+
timeout.addEventListener("abort", onTimeout, { once: true });
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
/** Upload `data` over a negotiated transfer. Resolves once every byte is sent. */
|
|
2318
|
+
async function uploadFileData(host, info, data, options = {}) {
|
|
2319
|
+
const socket = await dialFileTransfer(host, info.port, info.fileTransferKey, options);
|
|
2320
|
+
await pipeline(data, socket, options.signal ? { signal: options.signal } : {});
|
|
2321
|
+
}
|
|
2322
|
+
/** Download a negotiated transfer into `dest`. Resolves once the file is written. */
|
|
2323
|
+
async function downloadFileData(host, info, dest, options = {}) {
|
|
2324
|
+
const socket = await dialFileTransfer(host, info.port, info.fileTransferKey, options);
|
|
2325
|
+
await pipeline(socket, dest, options.signal ? { signal: options.signal } : {});
|
|
2326
|
+
}
|
|
2327
|
+
//#endregion
|
|
2328
|
+
//#region src/events.ts
|
|
2329
|
+
/**
|
|
2330
|
+
* Compose a chain of middlewares around a base handler.
|
|
2331
|
+
* Rightmost middleware wraps the base first.
|
|
2332
|
+
*/
|
|
2333
|
+
function buildCommandChain(middlewares, base) {
|
|
2334
|
+
let handler = base;
|
|
2335
|
+
for (let i = middlewares.length - 1; i >= 0; i--) handler = middlewares[i](handler);
|
|
2336
|
+
return handler;
|
|
2337
|
+
}
|
|
2338
|
+
function buildEventChain(middlewares, base) {
|
|
2339
|
+
let handler = base;
|
|
2340
|
+
for (let i = middlewares.length - 1; i >= 0; i--) handler = middlewares[i](handler);
|
|
2341
|
+
return handler;
|
|
2342
|
+
}
|
|
2343
|
+
//#endregion
|
|
2344
|
+
//#region src/capabilities.ts
|
|
2345
|
+
/**
|
|
2346
|
+
* Read what a server says about itself in `initserver`, or a version string
|
|
2347
|
+
* alone, such as "6.0.0-beta13.1 [Build: 1]".
|
|
2348
|
+
*/
|
|
2349
|
+
function parseServerCapabilities(initserver) {
|
|
2350
|
+
const params = typeof initserver === "string" ? { virtualserver_version: initserver } : initserver;
|
|
2351
|
+
const version = params["virtualserver_version"] ?? "";
|
|
2352
|
+
const parsed = Number.parseInt(version, 10);
|
|
2353
|
+
const major = Number.isNaN(parsed) ? 0 : parsed;
|
|
2354
|
+
let family = "unknown";
|
|
2355
|
+
if (major === 3) family = "ts3";
|
|
2356
|
+
else if (major >= 6) family = "ts6";
|
|
2357
|
+
return {
|
|
2358
|
+
version,
|
|
2359
|
+
major,
|
|
2360
|
+
family,
|
|
2361
|
+
uidAlgorithm: uidAlgorithmForVersion(version),
|
|
2362
|
+
name: params["virtualserver_name"] ?? "",
|
|
2363
|
+
iconID: parseIconID(params["virtualserver_icon_id"]),
|
|
2364
|
+
hostBannerImageURL: params["virtualserver_hostbanner_gfx_url"]?.trim() || void 0,
|
|
2365
|
+
welcomeMessage: params["virtualserver_welcomemessage"] ?? ""
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Capabilities of a server that has not been connected to yet.
|
|
2370
|
+
*
|
|
2371
|
+
* The family is `unknown` — nothing has said which server this is — and
|
|
2372
|
+
* `uidAlgorithm` is SHA-1, the TS3 hash.
|
|
2373
|
+
*/
|
|
2374
|
+
function unknownServerCapabilities() {
|
|
2375
|
+
return parseServerCapabilities("");
|
|
2376
|
+
}
|
|
2377
|
+
//#endregion
|
|
2378
|
+
//#region src/handshake/license.ts
|
|
2379
|
+
var LICENSE_ROOT_KEY = new Uint8Array([
|
|
2380
|
+
205,
|
|
2381
|
+
13,
|
|
2382
|
+
226,
|
|
2383
|
+
174,
|
|
2384
|
+
212,
|
|
2385
|
+
99,
|
|
2386
|
+
69,
|
|
2387
|
+
80,
|
|
2388
|
+
154,
|
|
2389
|
+
126,
|
|
2390
|
+
60,
|
|
2391
|
+
253,
|
|
2392
|
+
143,
|
|
2393
|
+
104,
|
|
2394
|
+
179,
|
|
2395
|
+
220,
|
|
2396
|
+
117,
|
|
2397
|
+
85,
|
|
2398
|
+
178,
|
|
2399
|
+
157,
|
|
2400
|
+
204,
|
|
2401
|
+
236,
|
|
2402
|
+
115,
|
|
2403
|
+
205,
|
|
2404
|
+
24,
|
|
2405
|
+
117,
|
|
2406
|
+
15,
|
|
2407
|
+
153,
|
|
2408
|
+
56,
|
|
2409
|
+
18,
|
|
2410
|
+
64,
|
|
2411
|
+
138
|
|
2412
|
+
]);
|
|
2413
|
+
var LicenseChain = class {
|
|
2414
|
+
blocks;
|
|
2415
|
+
constructor(blocks) {
|
|
2416
|
+
this.blocks = blocks;
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Derive the session key by chaining Ed25519 point arithmetic starting from
|
|
2420
|
+
* the root key through each license block.
|
|
2421
|
+
*/
|
|
2422
|
+
deriveKey() {
|
|
2423
|
+
let round = Uint8Array.from(LICENSE_ROOT_KEY);
|
|
2424
|
+
for (const block of this.blocks) round = deriveKeyFromBlock(block, round);
|
|
2425
|
+
return round;
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
function parseLicenses(data) {
|
|
2429
|
+
if (data.length < 1) throw new Error("license too short");
|
|
2430
|
+
if (data[0] !== 1) throw new Error("unsupported license version");
|
|
2431
|
+
let remaining = data.slice(1);
|
|
2432
|
+
const blocks = [];
|
|
2433
|
+
while (remaining.length > 0) {
|
|
2434
|
+
if (blocks.length === 8) throw new Error(`license chain longer than 8 blocks`);
|
|
2435
|
+
const { block, consumed } = parseLicenseBlock(remaining);
|
|
2436
|
+
blocks.push(block);
|
|
2437
|
+
remaining = remaining.slice(consumed);
|
|
2438
|
+
}
|
|
2439
|
+
return new LicenseChain(blocks);
|
|
2440
|
+
}
|
|
2441
|
+
function parseLicenseBlock(data) {
|
|
2442
|
+
const MIN_BLOCK_LEN = 42;
|
|
2443
|
+
if (data.length < MIN_BLOCK_LEN) throw new Error("license too short");
|
|
2444
|
+
if (data[0] !== 0) throw new Error(`wrong key kind in license: ${data[0]}`);
|
|
2445
|
+
const blockType = data[33];
|
|
2446
|
+
const UNIX_OFFSET = 1356998400;
|
|
2447
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
2448
|
+
const notValidBefore = /* @__PURE__ */ new Date((view.getUint32(34, false) + UNIX_OFFSET) * 1e3);
|
|
2449
|
+
const notValidAfter = /* @__PURE__ */ new Date((view.getUint32(38, false) + UNIX_OFFSET) * 1e3);
|
|
2450
|
+
if (notValidAfter < notValidBefore) throw new Error("license times are invalid");
|
|
2451
|
+
const key = Uint8Array.from(data.slice(1, 33));
|
|
2452
|
+
const { payload, payloadRead } = parseBlockPayload(blockType, data, MIN_BLOCK_LEN);
|
|
2453
|
+
const allLen = MIN_BLOCK_LEN + payloadRead;
|
|
2454
|
+
if (allLen > data.length) throw new Error("license block longer than the license");
|
|
2455
|
+
const hashInput = data.slice(1, allLen);
|
|
2456
|
+
const hashFull = hash512(Uint8Array.from(hashInput));
|
|
2457
|
+
return {
|
|
2458
|
+
block: {
|
|
2459
|
+
key,
|
|
2460
|
+
hash: Uint8Array.from(hashFull.slice(0, 32)),
|
|
2461
|
+
properties: payload.properties,
|
|
2462
|
+
issuer: payload.issuer,
|
|
2463
|
+
notValidBefore,
|
|
2464
|
+
notValidAfter,
|
|
2465
|
+
blockType,
|
|
2466
|
+
serverType: payload.serverType
|
|
2467
|
+
},
|
|
2468
|
+
consumed: allLen
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
function parseBlockPayload(blockType, data, minBlockLen) {
|
|
2472
|
+
switch (blockType) {
|
|
2473
|
+
case 0: {
|
|
2474
|
+
const { str, read } = readNullString(data.slice(46));
|
|
2475
|
+
return {
|
|
2476
|
+
payload: {
|
|
2477
|
+
issuer: str,
|
|
2478
|
+
serverType: 0,
|
|
2479
|
+
properties: [],
|
|
2480
|
+
read: 5 + read
|
|
2481
|
+
},
|
|
2482
|
+
payloadRead: 5 + read
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
case 2: {
|
|
2486
|
+
const { str, read } = readNullString(data.slice(47));
|
|
2487
|
+
return {
|
|
2488
|
+
payload: {
|
|
2489
|
+
issuer: str,
|
|
2490
|
+
serverType: data[42] ?? 0,
|
|
2491
|
+
properties: [],
|
|
2492
|
+
read: 6 + read
|
|
2493
|
+
},
|
|
2494
|
+
payloadRead: 6 + read
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
case 8: {
|
|
2498
|
+
const propCount = data[43] !== void 0 ? data[43] : 0;
|
|
2499
|
+
let pos = 44;
|
|
2500
|
+
const properties = [];
|
|
2501
|
+
for (let i = 0; i < propCount; i++) {
|
|
2502
|
+
if (pos >= data.length) throw new Error("license too short");
|
|
2503
|
+
const propLen = data[pos++];
|
|
2504
|
+
if (pos + propLen > data.length) throw new Error("license too short");
|
|
2505
|
+
properties.push(Uint8Array.from(data.slice(pos, pos + propLen)));
|
|
2506
|
+
pos += propLen;
|
|
2507
|
+
}
|
|
2508
|
+
return {
|
|
2509
|
+
payload: {
|
|
2510
|
+
issuer: "",
|
|
2511
|
+
serverType: data[42] ?? 0,
|
|
2512
|
+
properties,
|
|
2513
|
+
read: pos - minBlockLen
|
|
2514
|
+
},
|
|
2515
|
+
payloadRead: pos - minBlockLen
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
case 32: return {
|
|
2519
|
+
payload: {
|
|
2520
|
+
issuer: "",
|
|
2521
|
+
serverType: 0,
|
|
2522
|
+
properties: [],
|
|
2523
|
+
read: 0
|
|
2524
|
+
},
|
|
2525
|
+
payloadRead: 0
|
|
2526
|
+
};
|
|
2527
|
+
default: throw new Error(`invalid license block type: ${blockType}`);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Scan for a null-terminated string and return its content and the index
|
|
2532
|
+
* of the null byte (NOT including it in the count, matching Go behaviour).
|
|
2533
|
+
* The parent formulas (`5 + read`, `6 + read`) already add 1 for the null.
|
|
2534
|
+
*/
|
|
2535
|
+
function readNullString(data) {
|
|
2536
|
+
for (let i = 0; i < data.length; i++) if (data[i] === 0) return {
|
|
2537
|
+
str: new TextDecoder().decode(data.slice(0, i)),
|
|
2538
|
+
read: i
|
|
2539
|
+
};
|
|
2540
|
+
throw new Error("non-null-terminated issuer string");
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Derive the next key given a parent key and a license block.
|
|
2544
|
+
* Mirrors Go's licenseBlock.deriveKey.
|
|
2545
|
+
*/
|
|
2546
|
+
function deriveKeyFromBlock(block, parent) {
|
|
2547
|
+
const scalarBytes = Uint8Array.from(block.hash);
|
|
2548
|
+
clampScalar(scalarBytes);
|
|
2549
|
+
const scalar = bytesToBigIntLE(scalarBytes);
|
|
2550
|
+
const negPub = ed25519.Point.fromBytes(block.key).negate();
|
|
2551
|
+
const negPar = ed25519.Point.fromBytes(parent).negate();
|
|
2552
|
+
const raw = scalarMultFull(negPub, scalar).add(negPar).toBytes();
|
|
2553
|
+
const final = new Uint8Array(raw.length);
|
|
2554
|
+
final.set(raw);
|
|
2555
|
+
final[31] = (final[31] !== void 0 ? final[31] : 0) ^ 128;
|
|
2556
|
+
return final;
|
|
2557
|
+
}
|
|
2558
|
+
//#endregion
|
|
2559
|
+
//#region src/handshake/crypt-init2.ts
|
|
2560
|
+
/**
|
|
2561
|
+
* CryptoInit2 performs the second stage of crypto initialization (Ed25519 ECDH).
|
|
2562
|
+
* Mirrors Go's handshake.CryptoInit2.
|
|
2563
|
+
*/
|
|
2564
|
+
function cryptoInit2(crypt, license, omega, proof, beta, privateKey) {
|
|
2565
|
+
if (crypt.alpha.length === 0) throw new CryptoInitError("initivexpand2 arrived before clientinitiv was sent");
|
|
2566
|
+
const licenseBytes = Buffer.from(license, "base64");
|
|
2567
|
+
const omegaBytes = Buffer.from(omega, "base64");
|
|
2568
|
+
const proofBytes = Buffer.from(proof, "base64");
|
|
2569
|
+
const betaBytes = Buffer.from(beta, "base64");
|
|
2570
|
+
let serverPubKey;
|
|
2571
|
+
try {
|
|
2572
|
+
serverPubKey = importPublicKey(omegaBytes);
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
throw new CryptoInitError("the server's public key is invalid", { cause: error });
|
|
2575
|
+
}
|
|
2576
|
+
if (!verifySign(serverPubKey, licenseBytes, proofBytes)) throw new CryptoInitError("the server's license proof is not valid");
|
|
2577
|
+
let sharedSecret;
|
|
2578
|
+
try {
|
|
2579
|
+
sharedSecret = getSharedSecret2(parseLicenses(licenseBytes).deriveKey(), privateKey);
|
|
2580
|
+
} catch (error) {
|
|
2581
|
+
throw new CryptoInitError("the server's license is invalid", { cause: error });
|
|
2582
|
+
}
|
|
2583
|
+
crypt.setSharedSecret(crypt.alpha, betaBytes, sharedSecret);
|
|
2584
|
+
}
|
|
2585
|
+
//#endregion
|
|
2586
|
+
//#region src/handshake.ts
|
|
2587
|
+
/** Handle the `clientinitiv` message (P-256 based crypto path). */
|
|
2588
|
+
function handleHandshakeInitIV(client, params) {
|
|
2589
|
+
const alpha = params["alpha"] ?? "";
|
|
2590
|
+
const beta = params["beta"] ?? "";
|
|
2591
|
+
const omega = params["omega"] ?? "";
|
|
2592
|
+
client.crypt.initCrypto(alpha, beta, omega);
|
|
2593
|
+
client.logger.info("crypto initialized (P-256 path), sending clientinit");
|
|
2594
|
+
sendClientInit(client);
|
|
2595
|
+
}
|
|
2596
|
+
/** Handle the `initivexpand2` message (Ed25519 / TS3 crypto path). */
|
|
2597
|
+
function handleHandshakeExpand2(client, params) {
|
|
2598
|
+
client.logger.info("received initivexpand2");
|
|
2599
|
+
client.handler.receivedFinalInitAck();
|
|
2600
|
+
const license = params["l"] ?? "";
|
|
2601
|
+
const omega = params["omega"] ?? "";
|
|
2602
|
+
const proof = params["proof"] ?? "";
|
|
2603
|
+
const beta = params["beta"] ?? "";
|
|
2604
|
+
const privateKey = sendClientEkPacket(client, beta);
|
|
2605
|
+
cryptoInit2(client.crypt, license, omega, proof, beta, privateKey);
|
|
2606
|
+
sendClientInit(client);
|
|
2607
|
+
}
|
|
2608
|
+
/** Handle `initserver` — marks the client as connected. */
|
|
2609
|
+
function handleInitServer(client, params) {
|
|
2610
|
+
const idStr = params["aclid"] ?? params["clid"] ?? "";
|
|
2611
|
+
const clid = idStr ? parseInt(idStr, 10) : 0;
|
|
2612
|
+
if (clid > 0) {
|
|
2613
|
+
client.clid = clid;
|
|
2614
|
+
client.handler.setClientID(clid);
|
|
2615
|
+
}
|
|
2616
|
+
client.capabilities = parseServerCapabilities(params);
|
|
2617
|
+
client.logger.info("connected to server", { selfId: client.clid });
|
|
2618
|
+
client.markConnected();
|
|
2619
|
+
setImmediate(() => {
|
|
2620
|
+
const updateCmd = buildCommand("clientupdate", {
|
|
2621
|
+
client_input_muted: "0",
|
|
2622
|
+
client_output_muted: "0"
|
|
2623
|
+
});
|
|
2624
|
+
client.execNoWait(updateCmd).catch(() => {});
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
function sendClientEkPacket(client, beta) {
|
|
2628
|
+
const [publicKey, privateKey] = generateTemporaryKey();
|
|
2629
|
+
const ekBase64 = Buffer.from(publicKey).toString("base64");
|
|
2630
|
+
const clientProof = buildClientEkProof(client, publicKey, beta);
|
|
2631
|
+
const clientEk = buildCommandOrdered("clientek", [["ek", ekBase64], ["proof", clientProof]]);
|
|
2632
|
+
client.handler.sendPacket(PacketType.Command, Buffer.from(clientEk), 0);
|
|
2633
|
+
return privateKey;
|
|
2634
|
+
}
|
|
2635
|
+
function buildClientEkProof(client, publicKey, beta) {
|
|
2636
|
+
const betaBytes = Buffer.from(beta, "base64");
|
|
2637
|
+
const toSign = /* @__PURE__ */ new Uint8Array(86);
|
|
2638
|
+
toSign.set(publicKey.slice(0, 32));
|
|
2639
|
+
toSign.set(betaBytes.slice(0, Math.min(54, betaBytes.length)), 32);
|
|
2640
|
+
const sig = sign(identityPrivateKey(client.crypt.identity), toSign);
|
|
2641
|
+
return Buffer.from(sig).toString("base64");
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Encode a password field for the command protocol.
|
|
2645
|
+
*
|
|
2646
|
+
* TeamSpeak expects password fields as base64(sha1(password)) — server and
|
|
2647
|
+
* channel passwords during the handshake, the `clientmove` channel password,
|
|
2648
|
+
* and the `cpw` field of the file transfer commands. Sending the plain text
|
|
2649
|
+
* instead makes the server reject correct passwords with error 781.
|
|
2650
|
+
*/
|
|
2651
|
+
function prepareClientPassword(password) {
|
|
2652
|
+
if (password === "") return "";
|
|
2653
|
+
return createHash("sha1").update(password).digest().toString("base64");
|
|
2654
|
+
}
|
|
2655
|
+
function sendClientInit(client) {
|
|
2656
|
+
const cmd = buildClientInitCommand(client);
|
|
2657
|
+
client.handler.sendPacket(PacketType.Command, Buffer.from(cmd), 0);
|
|
2658
|
+
}
|
|
2659
|
+
function buildClientInitCommand(client) {
|
|
2660
|
+
const pubKeyBase64 = client.crypt.identity.publicKey();
|
|
2661
|
+
const initOptions = client.initOptions;
|
|
2662
|
+
const hwid = createHash("sha1").update(pubKeyBase64).digest().toString("base64");
|
|
2663
|
+
return buildCommandOrdered("clientinit", [
|
|
2664
|
+
["client_nickname", client.nickname],
|
|
2665
|
+
["client_version", "3.?.? [Build: 5680278000]"],
|
|
2666
|
+
["client_platform", "Windows"],
|
|
2667
|
+
["client_input_hardware", "1"],
|
|
2668
|
+
["client_output_hardware", "1"],
|
|
2669
|
+
["client_default_channel", initOptions.defaultChannel],
|
|
2670
|
+
["client_default_channel_password", initOptions.defaultChannelPasswordHash],
|
|
2671
|
+
["client_server_password", initOptions.serverPasswordHash],
|
|
2672
|
+
["client_meta_data", ""],
|
|
2673
|
+
["client_version_sign", "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg=="],
|
|
2674
|
+
["client_key_offset", String(client.crypt.identity.offset)],
|
|
2675
|
+
["client_nickname_phonetic", ""],
|
|
2676
|
+
["client_default_token", ""],
|
|
2677
|
+
["hwid", hwid]
|
|
2678
|
+
]);
|
|
2679
|
+
}
|
|
2680
|
+
//#endregion
|
|
2681
|
+
//#region src/notifications.ts
|
|
2682
|
+
function handleNotification(cmd, selfCLID, clients, _nickname) {
|
|
2683
|
+
switch (cmd.name) {
|
|
2684
|
+
case "notifycliententerview": return handleClientEnterView(cmd, clients);
|
|
2685
|
+
case "notifyclientleftview": return handleClientLeftView(cmd, selfCLID, clients);
|
|
2686
|
+
case "notifyclientmoved": return handleClientMoved(cmd, clients);
|
|
2687
|
+
case "notifytextmessage": return handleTextMessage(cmd, clients);
|
|
2688
|
+
case "notifyclientpoke": return handleClientPoked(cmd);
|
|
2689
|
+
default: return { kind: "unknown" };
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
function handleClientEnterView(cmd, clients) {
|
|
2693
|
+
const info = parseClient(cmd.params);
|
|
2694
|
+
if (info.id === 0) return { kind: "unknown" };
|
|
2695
|
+
clients.set(info.id, info);
|
|
2696
|
+
return {
|
|
2697
|
+
kind: "clientEnter",
|
|
2698
|
+
info
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
function handleClientLeftView(cmd, selfCLID, clients) {
|
|
2702
|
+
const clid = asClientID(parseUint16(cmd.params["clid"] ?? ""));
|
|
2703
|
+
if (clid === 0) return { kind: "unknown" };
|
|
2704
|
+
const isSelf = clid === selfCLID;
|
|
2705
|
+
clients.delete(clid);
|
|
2706
|
+
return {
|
|
2707
|
+
kind: "clientLeave",
|
|
2708
|
+
event: {
|
|
2709
|
+
clientID: clid,
|
|
2710
|
+
reason: parseInt10(cmd.params["reasonid"] ?? ""),
|
|
2711
|
+
reasonMessage: cmd.params["reasonmsg"] ?? "",
|
|
2712
|
+
invokerID: asClientID(parseUint16(cmd.params["invokerid"] ?? "")),
|
|
2713
|
+
invokerName: cmd.params["invokername"] ?? "",
|
|
2714
|
+
invokerUID: asUID(cmd.params["invokeruid"] ?? "")
|
|
2715
|
+
},
|
|
2716
|
+
isSelf
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
function handleClientMoved(cmd, clients) {
|
|
2720
|
+
const clid = asClientID(parseUint16(cmd.params["clid"] ?? ""));
|
|
2721
|
+
const ctid = asChannelID(parseUint64(cmd.params["ctid"] ?? ""));
|
|
2722
|
+
if (clid === 0) return { kind: "unknown" };
|
|
2723
|
+
const existing = clients.get(clid);
|
|
2724
|
+
if (existing) clients.set(clid, {
|
|
2725
|
+
...existing,
|
|
2726
|
+
channelID: ctid
|
|
2727
|
+
});
|
|
2728
|
+
return {
|
|
2729
|
+
kind: "clientMoved",
|
|
2730
|
+
event: {
|
|
2731
|
+
clientID: clid,
|
|
2732
|
+
targetChannelID: ctid,
|
|
2733
|
+
reason: parseInt10(cmd.params["reasonid"] ?? ""),
|
|
2734
|
+
invokerID: asClientID(parseUint16(cmd.params["invokerid"] ?? "")),
|
|
2735
|
+
invokerName: cmd.params["invokername"] ?? "",
|
|
2736
|
+
invokerUID: asUID(cmd.params["invokeruid"] ?? "")
|
|
2737
|
+
},
|
|
2738
|
+
reasonMessage: cmd.params["reasonmsg"] ?? ""
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
function handleTextMessage(cmd, clients) {
|
|
2742
|
+
const invokerID = asClientID(parseUint16(cmd.params["invokerid"] ?? ""));
|
|
2743
|
+
const invokerInfo = clients.get(invokerID);
|
|
2744
|
+
return {
|
|
2745
|
+
kind: "textMessage",
|
|
2746
|
+
message: {
|
|
2747
|
+
targetMode: parseInt10(cmd.params["targetmode"] ?? ""),
|
|
2748
|
+
targetID: asClientID(parseUint16(cmd.params["target"] ?? "")),
|
|
2749
|
+
invokerID,
|
|
2750
|
+
invokerName: cmd.params["invokername"] ?? "",
|
|
2751
|
+
invokerUID: asUID(cmd.params["invokeruid"] ?? invokerInfo?.uid ?? ""),
|
|
2752
|
+
message: cmd.params["msg"] ?? "",
|
|
2753
|
+
invokerGroups: invokerInfo?.serverGroups ?? []
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
function handleClientPoked(cmd) {
|
|
2758
|
+
return {
|
|
2759
|
+
kind: "poked",
|
|
2760
|
+
event: {
|
|
2761
|
+
invokerID: asClientID(parseUint16(cmd.params["invokerid"] ?? "")),
|
|
2762
|
+
invokerName: cmd.params["invokername"] ?? "",
|
|
2763
|
+
invokerUID: asUID(cmd.params["invokeruid"] ?? ""),
|
|
2764
|
+
message: cmd.params["msg"] ?? ""
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
//#endregion
|
|
2769
|
+
//#region src/throttle.ts
|
|
2770
|
+
/**
|
|
2771
|
+
* Token-bucket limiter for outbound TS3 commands.
|
|
2772
|
+
* Mirrors Go's commandThrottle.
|
|
2773
|
+
*
|
|
2774
|
+
* Waiters are released in the order they arrived: each used to poll for a
|
|
2775
|
+
* token on its own timer, so a command that came later could take the turn of
|
|
2776
|
+
* one that had waited longer, and the commands went out of order.
|
|
2777
|
+
*/
|
|
2778
|
+
var CommandThrottle = class CommandThrottle {
|
|
2779
|
+
static TOKEN_RATE = 4;
|
|
2780
|
+
static TOKEN_MAX = 8;
|
|
2781
|
+
#tokens = 5;
|
|
2782
|
+
#lastUpdate = Date.now();
|
|
2783
|
+
#waiters = [];
|
|
2784
|
+
#timer;
|
|
2785
|
+
/**
|
|
2786
|
+
* Wait for a token, behind every earlier waiter. Aborting `signal` rejects
|
|
2787
|
+
* with its reason and gives the place in line up; the abort listener is
|
|
2788
|
+
* removed however the wait ends.
|
|
2789
|
+
*/
|
|
2790
|
+
wait(signal) {
|
|
2791
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
2792
|
+
this.#refill();
|
|
2793
|
+
if (this.#waiters.length === 0 && this.#tokens >= 1) {
|
|
2794
|
+
this.#tokens -= 1;
|
|
2795
|
+
return Promise.resolve();
|
|
2796
|
+
}
|
|
2797
|
+
return new Promise((resolve, reject) => {
|
|
2798
|
+
const waiter = {
|
|
2799
|
+
resolve,
|
|
2800
|
+
signal,
|
|
2801
|
+
onAbort: () => {
|
|
2802
|
+
const index = this.#waiters.indexOf(waiter);
|
|
2803
|
+
if (index >= 0) this.#waiters.splice(index, 1);
|
|
2804
|
+
if (this.#waiters.length === 0) this.#stopTimer();
|
|
2805
|
+
reject(signal.reason);
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
|
2809
|
+
this.#waiters.push(waiter);
|
|
2810
|
+
this.#schedule();
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
#refill() {
|
|
2814
|
+
const now = Date.now();
|
|
2815
|
+
const elapsed = (now - this.#lastUpdate) / 1e3;
|
|
2816
|
+
this.#tokens = Math.min(this.#tokens + elapsed * CommandThrottle.TOKEN_RATE, CommandThrottle.TOKEN_MAX);
|
|
2817
|
+
this.#lastUpdate = now;
|
|
2818
|
+
}
|
|
2819
|
+
/** Arm the timer for when the next token is due, unless it already is. */
|
|
2820
|
+
#schedule() {
|
|
2821
|
+
if (this.#timer !== void 0 || this.#waiters.length === 0) return;
|
|
2822
|
+
const waitMs = this.#tokens >= 1 ? 0 : Math.ceil((1 - this.#tokens) / CommandThrottle.TOKEN_RATE * 1e3) + 10;
|
|
2823
|
+
this.#timer = setTimeout(() => {
|
|
2824
|
+
this.#timer = void 0;
|
|
2825
|
+
this.#release();
|
|
2826
|
+
}, waitMs);
|
|
2827
|
+
}
|
|
2828
|
+
#stopTimer() {
|
|
2829
|
+
clearTimeout(this.#timer);
|
|
2830
|
+
this.#timer = void 0;
|
|
2831
|
+
}
|
|
2832
|
+
/** Hand the tokens there are to the waiters at the head of the line. */
|
|
2833
|
+
#release() {
|
|
2834
|
+
this.#refill();
|
|
2835
|
+
while (this.#waiters.length > 0 && this.#tokens >= 1) {
|
|
2836
|
+
this.#tokens -= 1;
|
|
2837
|
+
const waiter = this.#waiters.shift();
|
|
2838
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
2839
|
+
waiter.resolve();
|
|
2840
|
+
}
|
|
2841
|
+
this.#schedule();
|
|
2842
|
+
}
|
|
2843
|
+
};
|
|
2844
|
+
//#endregion
|
|
2845
|
+
//#region src/helpers.ts
|
|
2846
|
+
/**
|
|
2847
|
+
* Reports whether `actual` equals `expected` or equals `expected` followed by
|
|
2848
|
+
* only digits — the pattern TeamSpeak uses when a nickname is already taken.
|
|
2849
|
+
*/
|
|
2850
|
+
function isAutoNicknameMatch(expected, actual) {
|
|
2851
|
+
if (actual === expected) return true;
|
|
2852
|
+
if (!actual.startsWith(expected)) return false;
|
|
2853
|
+
const suffix = actual.slice(expected.length);
|
|
2854
|
+
return /^\d+$/.test(suffix);
|
|
2855
|
+
}
|
|
2856
|
+
//#endregion
|
|
2857
|
+
//#region src/diagnostics.ts
|
|
2858
|
+
/**
|
|
2859
|
+
* Diagnostics channels: the SDK's tracing points, for an application or an
|
|
2860
|
+
* APM agent (OpenTelemetry instruments `diagnostics_channel` directly) to
|
|
2861
|
+
* subscribe to.
|
|
2862
|
+
*
|
|
2863
|
+
* A library does not decide where its traces go, so the SDK publishes them
|
|
2864
|
+
* here, and with no subscriber a channel costs a property read.
|
|
2865
|
+
*
|
|
2866
|
+
* Messages carry the command's name, never its parameters: those hold
|
|
2867
|
+
* password hashes, which the server accepts as passwords, and privilege keys.
|
|
2868
|
+
*
|
|
2869
|
+
* ```ts
|
|
2870
|
+
* import { tracingChannel } from "node:diagnostics_channel";
|
|
2871
|
+
*
|
|
2872
|
+
* tracingChannel("honeybbq:teamspeak:command").subscribe({
|
|
2873
|
+
* start: ({ command }) => console.time(command),
|
|
2874
|
+
* asyncEnd: ({ command }) => console.timeEnd(command),
|
|
2875
|
+
* error: ({ command, error }) => console.error(command, error),
|
|
2876
|
+
* });
|
|
2877
|
+
* ```
|
|
2878
|
+
*/
|
|
2879
|
+
/**
|
|
2880
|
+
* Traces every command with an answer: `start` when it is sent, `asyncEnd`
|
|
2881
|
+
* when its answer arrives, `error` when it fails.
|
|
2882
|
+
*/
|
|
2883
|
+
var commandChannel = tracingChannel("honeybbq:teamspeak:command");
|
|
2884
|
+
/** Publishes every command the server sends. */
|
|
2885
|
+
var packetChannel = channel("honeybbq:teamspeak:packet");
|
|
2886
|
+
//#endregion
|
|
2887
|
+
//#region src/reconnect.ts
|
|
2888
|
+
/**
|
|
2889
|
+
* Reconnection policy.
|
|
2890
|
+
*
|
|
2891
|
+
* A dropped connection is normal on a UDP voice session, so a bot that cannot
|
|
2892
|
+
* recover is not usable unattended. Reconnecting is opt-in rather than
|
|
2893
|
+
* automatic: a silent reconnect makes a dropped session look like a healthy
|
|
2894
|
+
* one, and a caller that wanted to know about the drop never finds out.
|
|
2895
|
+
*
|
|
2896
|
+
* Backoff is not a nicety here. TeamSpeak bans an IP that opens several
|
|
2897
|
+
* connections in quick succession — measured at roughly three, after which the
|
|
2898
|
+
* server answers `error id=3329` and refuses everything. A reconnect loop
|
|
2899
|
+
* without a delay therefore bans itself, and the ban outlasts the outage it was
|
|
2900
|
+
* recovering from.
|
|
2901
|
+
*/
|
|
2902
|
+
/** One second, doubling, capped at a minute, with 20% jitter, never giving up. */
|
|
2903
|
+
var defaultBackoffPolicy = Object.freeze({
|
|
2904
|
+
initialDelayMs: 1e3,
|
|
2905
|
+
maxDelayMs: 6e4,
|
|
2906
|
+
factor: 2,
|
|
2907
|
+
jitter: .2,
|
|
2908
|
+
maxAttempts: 0
|
|
2909
|
+
});
|
|
2910
|
+
/**
|
|
2911
|
+
* Compute the delay before an attempt.
|
|
2912
|
+
*
|
|
2913
|
+
* `attempt` is 1-based: the delay before the first retry is `initialDelayMs`,
|
|
2914
|
+
* give or take the jitter, and the delay never grows past `maxDelayMs`.
|
|
2915
|
+
*/
|
|
2916
|
+
function backoffDelay(policy, attempt) {
|
|
2917
|
+
return jitteredDelay(policy, attempt, Math.random);
|
|
2918
|
+
}
|
|
2919
|
+
/** @internal The delay, with `random` supplying the jitter. */
|
|
2920
|
+
function jitteredDelay(policy, attempt, random) {
|
|
2921
|
+
const exponent = Math.max(0, attempt - 1);
|
|
2922
|
+
const raw = policy.initialDelayMs * policy.factor ** exponent;
|
|
2923
|
+
const capped = Math.min(raw, policy.maxDelayMs);
|
|
2924
|
+
if (policy.jitter <= 0) return Math.round(capped);
|
|
2925
|
+
const spread = capped * Math.min(policy.jitter, 1);
|
|
2926
|
+
const delay = capped + (random() * 2 - 1) * spread;
|
|
2927
|
+
return Math.round(Math.max(0, Math.min(delay, policy.maxDelayMs)));
|
|
2928
|
+
}
|
|
2929
|
+
/**
|
|
2930
|
+
* Server errors where retrying cannot help.
|
|
2931
|
+
*
|
|
2932
|
+
* Deliberately a short list of codes known to be refusals, rather than "any
|
|
2933
|
+
* ServerError". The server also answers transiently: after a dropped
|
|
2934
|
+
* connection it still holds the old session, and a reconnect with the same
|
|
2935
|
+
* identity is refused with 521 "too many clones already connected" until it
|
|
2936
|
+
* reaps the zombie — a condition that clears itself and is worth retrying.
|
|
2937
|
+
*
|
|
2938
|
+
* The asymmetry is deliberate. Retrying a permanent failure costs a few bounded
|
|
2939
|
+
* attempts; not retrying a transient one means never recovering.
|
|
2940
|
+
*/
|
|
2941
|
+
var PERMANENT_SERVER_ERRORS = /* @__PURE__ */ new Set([
|
|
2942
|
+
ErrorCode.ConnectFailedBanned,
|
|
2943
|
+
ErrorCode.BanFlooding,
|
|
2944
|
+
ErrorCode.ServerInvalidPassword,
|
|
2945
|
+
ErrorCode.ChannelInvalidPassword,
|
|
2946
|
+
ErrorCode.PermissionsClientInsufficient
|
|
2947
|
+
]);
|
|
2948
|
+
/**
|
|
2949
|
+
* Whether a failure is worth another attempt.
|
|
2950
|
+
*
|
|
2951
|
+
* Transport failures are retried; so is a server that stopped, and a server
|
|
2952
|
+
* error other than the known refusals — a ban, flooding, a wrong server or
|
|
2953
|
+
* channel password, missing permissions — since some, such as 521 "too many
|
|
2954
|
+
* clones" right after a drop, clear by themselves. A kick or a ban is not:
|
|
2955
|
+
* someone removed the client on purpose. Nor is an AlreadyConnectedError: the
|
|
2956
|
+
* client was connected by other means; nor an InvalidAddressError.
|
|
2957
|
+
*/
|
|
2958
|
+
function defaultShouldRetry(error) {
|
|
2959
|
+
if (error instanceof AlreadyConnectedError) return false;
|
|
2960
|
+
if (error instanceof InvalidAddressError) return false;
|
|
2961
|
+
if (error instanceof RemovedError) return error.reason !== Reason.KickServer && error.reason !== Reason.KickServerBan;
|
|
2962
|
+
return !(error instanceof ServerError && PERMANENT_SERVER_ERRORS.has(error.code));
|
|
2963
|
+
}
|
|
2964
|
+
function resolveBackoffPolicy(options) {
|
|
2965
|
+
const policy = { ...defaultBackoffPolicy };
|
|
2966
|
+
for (const [key, value] of Object.entries(options.backoff ?? {})) if (value !== void 0) policy[key] = value;
|
|
2967
|
+
return policy;
|
|
2968
|
+
}
|
|
2969
|
+
//#endregion
|
|
2970
|
+
//#region src/client.ts
|
|
2971
|
+
function normalizeClientInitOptions(options) {
|
|
2972
|
+
return {
|
|
2973
|
+
serverPasswordHash: prepareClientPassword(options.serverPassword ?? ""),
|
|
2974
|
+
defaultChannel: options.defaultChannel ?? "",
|
|
2975
|
+
defaultChannelPasswordHash: prepareClientPassword(options.defaultChannelPassword ?? "")
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
/** How long a command waits for its answer unless told otherwise. */
|
|
2979
|
+
var DEFAULT_COMMAND_TIMEOUT_MS = 1e4;
|
|
2980
|
+
/**
|
|
2981
|
+
* How long `disconnect()` waits for the server to acknowledge its goodbye
|
|
2982
|
+
* unless told otherwise: the acknowledgement is a courtesy, and a connection
|
|
2983
|
+
* that has already died would otherwise hold it for a full command timeout.
|
|
2984
|
+
*/
|
|
2985
|
+
var DISCONNECT_TIMEOUT_MS = 1e3;
|
|
2986
|
+
/** How long each reconnection attempt may take. */
|
|
2987
|
+
var RECONNECT_ATTEMPT_TIMEOUT_MS = 3e4;
|
|
2988
|
+
function newConnectAttempt() {
|
|
2989
|
+
let resolve;
|
|
2990
|
+
let reject;
|
|
2991
|
+
const promise = new Promise((res, rej) => {
|
|
2992
|
+
resolve = res;
|
|
2993
|
+
reject = rej;
|
|
2994
|
+
});
|
|
2995
|
+
promise.catch(() => {});
|
|
2996
|
+
const controller = new AbortController();
|
|
2997
|
+
const attempt = {
|
|
2998
|
+
promise,
|
|
2999
|
+
signal: controller.signal,
|
|
3000
|
+
settled: false,
|
|
3001
|
+
resolve() {
|
|
3002
|
+
if (attempt.settled) return;
|
|
3003
|
+
attempt.settled = true;
|
|
3004
|
+
resolve();
|
|
3005
|
+
},
|
|
3006
|
+
reject(error) {
|
|
3007
|
+
if (attempt.settled) return;
|
|
3008
|
+
attempt.settled = true;
|
|
3009
|
+
controller.abort(error);
|
|
3010
|
+
reject(error);
|
|
3011
|
+
},
|
|
3012
|
+
rejectAfter(error, until) {
|
|
3013
|
+
if (attempt.settled) return;
|
|
3014
|
+
attempt.settled = true;
|
|
3015
|
+
controller.abort(error);
|
|
3016
|
+
until.finally(() => reject(error));
|
|
3017
|
+
}
|
|
3018
|
+
};
|
|
3019
|
+
return attempt;
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Voice deliveries are dropped while more than this many deliveries of any
|
|
3023
|
+
* kind are waiting: audio is fifty packets a second per speaker, and a
|
|
3024
|
+
* listener that cannot keep up would otherwise queue it without bound.
|
|
3025
|
+
*/
|
|
3026
|
+
var MAX_PENDING_DELIVERIES = 256;
|
|
3027
|
+
/** How often dropped voice is reported, at most. */
|
|
3028
|
+
var VOICE_DROP_WARN_INTERVAL_MS = 1e4;
|
|
3029
|
+
/** The only commands handled before crypto init completes. */
|
|
3030
|
+
var PRE_INIT_COMMANDS = /* @__PURE__ */ new Set([
|
|
3031
|
+
"clientinitiv",
|
|
3032
|
+
"initivexpand2",
|
|
3033
|
+
"error"
|
|
3034
|
+
]);
|
|
3035
|
+
/**
|
|
3036
|
+
* A TeamSpeak client over the voice protocol.
|
|
3037
|
+
*
|
|
3038
|
+
* Listeners run one at a time, in the order their events arrived, after the
|
|
3039
|
+
* packet that carried them has been handled, so a listener may call the client
|
|
3040
|
+
* and await an answer.
|
|
3041
|
+
*/
|
|
3042
|
+
var Client = class {
|
|
3043
|
+
static {}
|
|
3044
|
+
#crypt;
|
|
3045
|
+
#handler;
|
|
3046
|
+
#logger;
|
|
3047
|
+
#nickname;
|
|
3048
|
+
#clid = 0;
|
|
3049
|
+
/**
|
|
3050
|
+
* What the server reported about itself in initserver, including the UID
|
|
3051
|
+
* hash it uses: TS3 hashes the public key with SHA-1 and TS6 with SHA-256.
|
|
3052
|
+
*/
|
|
3053
|
+
#capabilities = unknownServerCapabilities();
|
|
3054
|
+
#internals;
|
|
3055
|
+
#identity;
|
|
3056
|
+
#addr;
|
|
3057
|
+
#resolver;
|
|
3058
|
+
#clientInitOptions;
|
|
3059
|
+
#socketFactory;
|
|
3060
|
+
#fileTransferSocketFactory;
|
|
3061
|
+
/** Undefined when the client does not reconnect. */
|
|
3062
|
+
#reconnectOptions;
|
|
3063
|
+
/**
|
|
3064
|
+
* Set while a reconnect attempt is in flight, so overlapping failures do not
|
|
3065
|
+
* start competing loops.
|
|
3066
|
+
*/
|
|
3067
|
+
#reconnecting = false;
|
|
3068
|
+
/**
|
|
3069
|
+
* Why the last established session dropped: a reconnect loop whose own
|
|
3070
|
+
* attempt connected and then dropped before the loop resumed retries for it.
|
|
3071
|
+
*/
|
|
3072
|
+
#lastDrop;
|
|
3073
|
+
/** Set by an explicit disconnect(), which must never be undone by a retry. */
|
|
3074
|
+
#stopped = false;
|
|
3075
|
+
/**
|
|
3076
|
+
* The goodbye of a disconnect() still in progress, which a connect() waits
|
|
3077
|
+
* for: tearing the old session down after a new one has started would end
|
|
3078
|
+
* the new one instead.
|
|
3079
|
+
*/
|
|
3080
|
+
#leaving;
|
|
3081
|
+
/** How many times disconnect() was called, so a waiting connect() sees one. */
|
|
3082
|
+
#disconnects = 0;
|
|
3083
|
+
/**
|
|
3084
|
+
* Settles once the last queued `disconnected` has been delivered. A
|
|
3085
|
+
* connect() waits for it, so the end of one session is never heard during
|
|
3086
|
+
* the next: not by a listener added for the new session, and not by an
|
|
3087
|
+
* events() iterator started for it.
|
|
3088
|
+
*/
|
|
3089
|
+
#disconnectedDelivered;
|
|
3090
|
+
/** Cuts short the reconnect loop's wait, on disconnect(). */
|
|
3091
|
+
#stopReconnecting;
|
|
3092
|
+
#status = ClientStatus.Disconnected;
|
|
3093
|
+
#throttle = new CommandThrottle();
|
|
3094
|
+
#cmdTrack = new CommandTracker(() => this.#clid);
|
|
3095
|
+
#clients = /* @__PURE__ */ new Map();
|
|
3096
|
+
#attempt = newConnectAttempt();
|
|
3097
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
3098
|
+
/** Deliveries waiting for their turn; see #emit. */
|
|
3099
|
+
#queue = [];
|
|
3100
|
+
#draining = false;
|
|
3101
|
+
#voiceDropped = 0;
|
|
3102
|
+
#voiceDropWarnedAt = -Infinity;
|
|
3103
|
+
#eventMiddlewares;
|
|
3104
|
+
#send;
|
|
3105
|
+
#eventChain;
|
|
3106
|
+
constructor(identity, addr, nickname, options = {}) {
|
|
3107
|
+
this.#identity = identity;
|
|
3108
|
+
this.#addr = addr;
|
|
3109
|
+
this.#nickname = nickname;
|
|
3110
|
+
this.#logger = options.logger ?? noopLogger;
|
|
3111
|
+
this.#resolver = options.resolver ?? new Resolver({ resolveNicknames: options.resolveNicknames });
|
|
3112
|
+
this.#clientInitOptions = normalizeClientInitOptions(options);
|
|
3113
|
+
this.#socketFactory = options.socketFactory;
|
|
3114
|
+
this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
|
|
3115
|
+
this.#reconnectOptions = options.reconnect;
|
|
3116
|
+
this.#crypt = new Crypt(identity);
|
|
3117
|
+
this.#handler = this.#newHandler();
|
|
3118
|
+
const self = this;
|
|
3119
|
+
this.#internals = {
|
|
3120
|
+
get crypt() {
|
|
3121
|
+
return self.#crypt;
|
|
3122
|
+
},
|
|
3123
|
+
get handler() {
|
|
3124
|
+
return self.#handler;
|
|
3125
|
+
},
|
|
3126
|
+
get logger() {
|
|
3127
|
+
return self.#logger;
|
|
3128
|
+
},
|
|
3129
|
+
get initOptions() {
|
|
3130
|
+
return self.#clientInitOptions;
|
|
3131
|
+
},
|
|
3132
|
+
get nickname() {
|
|
3133
|
+
return self.#nickname;
|
|
3134
|
+
},
|
|
3135
|
+
set nickname(value) {
|
|
3136
|
+
self.#nickname = value;
|
|
3137
|
+
},
|
|
3138
|
+
get clid() {
|
|
3139
|
+
return self.#clid;
|
|
3140
|
+
},
|
|
3141
|
+
set clid(value) {
|
|
3142
|
+
self.#clid = value;
|
|
3143
|
+
},
|
|
3144
|
+
get capabilities() {
|
|
3145
|
+
return self.#capabilities;
|
|
3146
|
+
},
|
|
3147
|
+
set capabilities(value) {
|
|
3148
|
+
self.#capabilities = value;
|
|
3149
|
+
},
|
|
3150
|
+
markConnected: () => this.#markConnected(),
|
|
3151
|
+
execNoWait: (command) => this.#execNoWait(command, void 0, true),
|
|
3152
|
+
handlePacket: (packet) => this.#handlePacket(packet)
|
|
3153
|
+
};
|
|
3154
|
+
const cmdMiddlewares = options.commandMiddleware ?? [];
|
|
3155
|
+
this.#eventMiddlewares = options.eventMiddleware ?? [];
|
|
3156
|
+
this.#send = buildCommandChain(cmdMiddlewares, (request, commandOptions = {}) => this.#exec(encodeRequest(request, prepareClientPassword), commandOptions, false));
|
|
3157
|
+
this.#eventChain = buildEventChain([...this.#eventMiddlewares], (event) => this.#deliver(event.name, event.payload));
|
|
3158
|
+
}
|
|
3159
|
+
/** Where the client is in its connection's life. */
|
|
3160
|
+
get status() {
|
|
3161
|
+
return this.#status;
|
|
3162
|
+
}
|
|
3163
|
+
/**
|
|
3164
|
+
* Connect to the server. Resolves once the handshake completes.
|
|
3165
|
+
*
|
|
3166
|
+
* An address that cannot be one — no host, a port outside 1–65535 — rejects
|
|
3167
|
+
* with an InvalidAddressError at once, before anything starts. A host name
|
|
3168
|
+
* that does not resolve fails the attempt with a TeamspeakError whose
|
|
3169
|
+
* `cause` is the lookup's error.
|
|
3170
|
+
*
|
|
3171
|
+
* A failed attempt rejects with its cause, such as the server's error for a
|
|
3172
|
+
* rejected password. Aborting `signal` abandons the attempt and rejects with
|
|
3173
|
+
* the signal's reason. The signal bounds only the connecting: once this
|
|
3174
|
+
* resolves, the connection lasts until `disconnect()` or a drop.
|
|
3175
|
+
* `connect()` does not retry; the `reconnect` option only retries a
|
|
3176
|
+
* connection that drops after it was established.
|
|
3177
|
+
*
|
|
3178
|
+
* Called while a `disconnect()` is still saying goodbye, or while an earlier
|
|
3179
|
+
* session's `disconnected` is still waiting to be delivered, this waits for
|
|
3180
|
+
* that first, so the old session is closed, and its `disconnected`
|
|
3181
|
+
* delivered, before the new one starts: no listener or `events()` iterator
|
|
3182
|
+
* of the new session hears the old one end. A `disconnect()` called during
|
|
3183
|
+
* that wait wins: this then rejects with a NotConnectedError.
|
|
3184
|
+
*
|
|
3185
|
+
* `connected` is delivered after this resolves, so a listener added right
|
|
3186
|
+
* after `await client.connect()` still hears it.
|
|
3187
|
+
*/
|
|
3188
|
+
async connect(options = {}) {
|
|
3189
|
+
const { signal } = options;
|
|
3190
|
+
signal?.throwIfAborted();
|
|
3191
|
+
const disconnects = this.#disconnects;
|
|
3192
|
+
if (this.#leaving !== void 0) await abortable(this.#leaving, signal);
|
|
3193
|
+
while (this.#disconnectedDelivered !== void 0) await abortable(this.#disconnectedDelivered, signal);
|
|
3194
|
+
if (this.#disconnects !== disconnects) throw new NotConnectedError();
|
|
3195
|
+
if (this.#status !== ClientStatus.Disconnected) throw new AlreadyConnectedError();
|
|
3196
|
+
parseConnectAddress(this.#addr);
|
|
3197
|
+
this.#stopped = false;
|
|
3198
|
+
this.#resetForConnect();
|
|
3199
|
+
this.#status = ClientStatus.Connecting;
|
|
3200
|
+
const attempt = this.#attempt;
|
|
3201
|
+
const onAbort = () => this.#failConnection(abortReason(signal));
|
|
3202
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3203
|
+
try {
|
|
3204
|
+
try {
|
|
3205
|
+
const targetAddr = await this.#resolveAddr(attempt.signal);
|
|
3206
|
+
if (!attempt.settled) {
|
|
3207
|
+
this.#logger.info("connecting to server", { address: targetAddr });
|
|
3208
|
+
await this.#handler.connect(targetAddr, attempt.signal);
|
|
3209
|
+
}
|
|
3210
|
+
} catch (error) {
|
|
3211
|
+
this.#failConnection(error);
|
|
3212
|
+
}
|
|
3213
|
+
await attempt.promise;
|
|
3214
|
+
} finally {
|
|
3215
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
/**
|
|
3219
|
+
* Leave the server and close the connection.
|
|
3220
|
+
*
|
|
3221
|
+
* Says goodbye first, waiting for the server's acknowledgement for a second
|
|
3222
|
+
* at most, or less if `signal` aborts sooner. The connection is closed
|
|
3223
|
+
* either way. A `connect()` still waiting for its handshake rejects with a
|
|
3224
|
+
* NotConnectedError, and so do the commands still waiting for an answer.
|
|
3225
|
+
* Resolves once `disconnected` has been delivered to the listeners, so one
|
|
3226
|
+
* added after `await client.disconnect()` does not hear it. Does nothing on
|
|
3227
|
+
* a client that is not connected; called again while an earlier call is
|
|
3228
|
+
* still saying goodbye, it waits for that one.
|
|
3229
|
+
*/
|
|
3230
|
+
async disconnect(options = {}) {
|
|
3231
|
+
this.#stopped = true;
|
|
3232
|
+
this.#disconnects++;
|
|
3233
|
+
this.#stopReconnecting?.abort();
|
|
3234
|
+
if (this.#leaving !== void 0) return this.#leaving;
|
|
3235
|
+
if (this.#status === ClientStatus.Disconnected) return this.#disconnectedDelivered;
|
|
3236
|
+
const wasConnected = this.#status === ClientStatus.Connected;
|
|
3237
|
+
this.#status = ClientStatus.Disconnected;
|
|
3238
|
+
let left;
|
|
3239
|
+
this.#attempt.rejectAfter(new NotConnectedError(), new Promise((r) => left = r));
|
|
3240
|
+
const leaving = this.#leave(wasConnected, options.signal).finally(left);
|
|
3241
|
+
this.#leaving = leaving;
|
|
3242
|
+
try {
|
|
3243
|
+
await leaving;
|
|
3244
|
+
} finally {
|
|
3245
|
+
this.#leaving = void 0;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
async #leave(wasConnected, signal) {
|
|
3249
|
+
this.#logger.info("disconnecting from server");
|
|
3250
|
+
if (wasConnected) try {
|
|
3251
|
+
await this.#exec("clientdisconnect reasonmsg=Shutdown", {
|
|
3252
|
+
signal,
|
|
3253
|
+
timeout: DISCONNECT_TIMEOUT_MS
|
|
3254
|
+
}, true);
|
|
3255
|
+
} catch {}
|
|
3256
|
+
this.#handler.close();
|
|
3257
|
+
this.#cmdTrack.reset(new NotConnectedError());
|
|
3258
|
+
await this.#announceDisconnected(void 0);
|
|
3259
|
+
}
|
|
3260
|
+
/** `await using client = …` disconnects when the scope ends. */
|
|
3261
|
+
[Symbol.asyncDispose]() {
|
|
3262
|
+
return this.disconnect();
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* Send a raw command line and return the rows that answer it: the escape
|
|
3266
|
+
* hatch for a command the typed functions do not cover. The line is sent as
|
|
3267
|
+
* given, so its values must already be escaped and any channel password
|
|
3268
|
+
* already hashed.
|
|
3269
|
+
*
|
|
3270
|
+
* Rejects with a NotConnectedError unless the client is connected: while it
|
|
3271
|
+
* is still connecting, the session's keys may not exist yet.
|
|
3272
|
+
*/
|
|
3273
|
+
exec(command, options = {}) {
|
|
3274
|
+
return this.#exec(command, options, false);
|
|
3275
|
+
}
|
|
3276
|
+
/**
|
|
3277
|
+
* Send a raw command line without waiting for an answer, for the few
|
|
3278
|
+
* commands the server answers with nothing at all.
|
|
3279
|
+
*
|
|
3280
|
+
* Commands are paced by a flood throttle; aborting `signal` while this waits
|
|
3281
|
+
* for its turn rejects with the signal's reason and sends nothing. Rejects
|
|
3282
|
+
* with a NotConnectedError unless the client is connected.
|
|
3283
|
+
*/
|
|
3284
|
+
execNoWait(command, options = {}) {
|
|
3285
|
+
return this.#execNoWait(command, options.signal, false);
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* Whether a command may go out now. The handshake's own commands (`internal`)
|
|
3289
|
+
* need only an open socket; everything else needs a connected session, so
|
|
3290
|
+
* nothing a caller sends can go out under the public dummy key, or after
|
|
3291
|
+
* disconnect() has begun saying goodbye.
|
|
3292
|
+
*/
|
|
3293
|
+
#canSend(internal) {
|
|
3294
|
+
return this.#handler.isOpen && (internal || this.#status === ClientStatus.Connected);
|
|
3295
|
+
}
|
|
3296
|
+
async #execNoWait(command, signal, internal) {
|
|
3297
|
+
signal?.throwIfAborted();
|
|
3298
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
3299
|
+
await this.#throttle.wait(signal);
|
|
3300
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
3301
|
+
this.#sendLine(command);
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Send a typed command; this is the CommandExecutor the typed commands run on.
|
|
3305
|
+
*
|
|
3306
|
+
* The request passes through the `commandMiddleware` first. Channel
|
|
3307
|
+
* passwords are then hashed, because that is this protocol's rule: the voice
|
|
3308
|
+
* protocol expects `base64(sha1(password))`, where ServerQuery takes the
|
|
3309
|
+
* password itself.
|
|
3310
|
+
*/
|
|
3311
|
+
send(request, options = {}) {
|
|
3312
|
+
return this.#send(request, options);
|
|
3313
|
+
}
|
|
3314
|
+
#sendLine(line) {
|
|
3315
|
+
this.#handler.sendPacket(PacketType.Command, Buffer.from(line), 0);
|
|
3316
|
+
}
|
|
3317
|
+
#exec(command, options, internal) {
|
|
3318
|
+
if (!commandChannel.hasSubscribers) return this.#execUntraced(command, options, internal);
|
|
3319
|
+
const trace = {
|
|
3320
|
+
command: parseCommand(command)?.name ?? "",
|
|
3321
|
+
client: this
|
|
3322
|
+
};
|
|
3323
|
+
return commandChannel.tracePromise(() => this.#execUntraced(command, options, internal), trace);
|
|
3324
|
+
}
|
|
3325
|
+
async #execUntraced(command, options, internal) {
|
|
3326
|
+
const { signal, timeout = DEFAULT_COMMAND_TIMEOUT_MS } = options;
|
|
3327
|
+
signal?.throwIfAborted();
|
|
3328
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
3329
|
+
const parsed = parseCommand(command);
|
|
3330
|
+
const spec = responseSpec(parsed?.name ?? "");
|
|
3331
|
+
const key = spec?.correlated?.key;
|
|
3332
|
+
const correlation = key === void 0 ? void 0 : parsed?.params[key];
|
|
3333
|
+
const [rc, answered] = this.#cmdTrack.register(spec, correlation);
|
|
3334
|
+
let timer;
|
|
3335
|
+
let onAbort;
|
|
3336
|
+
const givenUp = new Promise((_, reject) => {
|
|
3337
|
+
timer = setTimeout(() => reject(new CommandTimeoutError(parsed?.name ?? "")), timeout);
|
|
3338
|
+
onAbort = () => reject(abortReason(signal));
|
|
3339
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3340
|
+
});
|
|
3341
|
+
givenUp.catch(() => {});
|
|
3342
|
+
const throttleAbort = new AbortController();
|
|
3343
|
+
try {
|
|
3344
|
+
const withRc = appendReturnCode(command, rc);
|
|
3345
|
+
await Promise.race([this.#throttle.wait(throttleAbort.signal), givenUp]);
|
|
3346
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
3347
|
+
this.#sendLine(withRc);
|
|
3348
|
+
const result = await Promise.race([answered, givenUp]);
|
|
3349
|
+
if (result.err) throw result.err;
|
|
3350
|
+
return result.data;
|
|
3351
|
+
} finally {
|
|
3352
|
+
throttleAbort.abort();
|
|
3353
|
+
clearTimeout(timer);
|
|
3354
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
3355
|
+
this.#cmdTrack.unregister(rc);
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Listen for an event. Returns a function that removes the listener.
|
|
3360
|
+
*
|
|
3361
|
+
* A listener that throws does not stop the others: the rest of the event's
|
|
3362
|
+
* listeners, and the events after it, are still delivered, and the exception
|
|
3363
|
+
* is rethrown on its own, from a microtask, so it surfaces as an
|
|
3364
|
+
* `uncaughtException` just as an EventEmitter listener's would. An async
|
|
3365
|
+
* listener's rejection is not caught, and surfaces as an unhandled
|
|
3366
|
+
* rejection.
|
|
3367
|
+
*/
|
|
3368
|
+
on(event, listener) {
|
|
3369
|
+
const subscription = {
|
|
3370
|
+
listener,
|
|
3371
|
+
removed: false
|
|
3372
|
+
};
|
|
3373
|
+
this.#listeners.set(event, [...this.#listeners.get(event) ?? [], subscription]);
|
|
3374
|
+
const unsubscribe = () => {
|
|
3375
|
+
if (subscription.removed) return;
|
|
3376
|
+
subscription.removed = true;
|
|
3377
|
+
const list = this.#listeners.get(event) ?? [];
|
|
3378
|
+
this.#listeners.set(event, list.filter((s) => s !== subscription));
|
|
3379
|
+
};
|
|
3380
|
+
return Object.assign(unsubscribe, { [Symbol.dispose]: unsubscribe });
|
|
3381
|
+
}
|
|
3382
|
+
/** Listen for the next occurrence of an event only. */
|
|
3383
|
+
once(event, listener) {
|
|
3384
|
+
const unsubscribe = this.on(event, ((payload) => {
|
|
3385
|
+
unsubscribe();
|
|
3386
|
+
listener(payload);
|
|
3387
|
+
}));
|
|
3388
|
+
return unsubscribe;
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* An event as an async iterator, for `for await`:
|
|
3392
|
+
*
|
|
3393
|
+
* ```ts
|
|
3394
|
+
* for await (const message of client.events("textMessage", { signal })) { … }
|
|
3395
|
+
* ```
|
|
3396
|
+
*
|
|
3397
|
+
* Events that arrive while the loop body runs are buffered, so none is
|
|
3398
|
+
* missed — except voice, of which at most 256 frames are buffered; the rest
|
|
3399
|
+
* are dropped, as for a listener that falls behind. Iteration ends when
|
|
3400
|
+
* `signal` aborts, when the loop exits early (`break`, `return`, a throw),
|
|
3401
|
+
* and — for every event but `disconnected` — when the client disconnects.
|
|
3402
|
+
* An abort stops listening at once and discards whatever is buffered.
|
|
3403
|
+
*/
|
|
3404
|
+
events(event, options = {}) {
|
|
3405
|
+
const { signal } = options;
|
|
3406
|
+
const buffered = [];
|
|
3407
|
+
let wake;
|
|
3408
|
+
let ended = false;
|
|
3409
|
+
const stopListening = this.on(event, ((payload) => {
|
|
3410
|
+
if (ended) return;
|
|
3411
|
+
if (event === "voiceData" && buffered.length >= MAX_PENDING_DELIVERIES) {
|
|
3412
|
+
this.#dropVoice();
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
buffered.push(payload);
|
|
3416
|
+
wake?.();
|
|
3417
|
+
}));
|
|
3418
|
+
const stopOnDisconnect = event === "disconnected" ? void 0 : this.on("disconnected", () => {
|
|
3419
|
+
ended = true;
|
|
3420
|
+
stopListening();
|
|
3421
|
+
wake?.();
|
|
3422
|
+
});
|
|
3423
|
+
const cleanup = () => {
|
|
3424
|
+
ended = true;
|
|
3425
|
+
stopListening();
|
|
3426
|
+
stopOnDisconnect?.();
|
|
3427
|
+
signal?.removeEventListener("abort", abort);
|
|
3428
|
+
wake?.();
|
|
3429
|
+
};
|
|
3430
|
+
const abort = () => {
|
|
3431
|
+
buffered.length = 0;
|
|
3432
|
+
cleanup();
|
|
3433
|
+
};
|
|
3434
|
+
if (signal?.aborted) abort();
|
|
3435
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
3436
|
+
const iterator = {
|
|
3437
|
+
async next() {
|
|
3438
|
+
while (buffered.length === 0 && !ended) {
|
|
3439
|
+
await new Promise((resolve) => wake = resolve);
|
|
3440
|
+
wake = void 0;
|
|
3441
|
+
}
|
|
3442
|
+
if (buffered.length > 0) return {
|
|
3443
|
+
value: buffered.shift(),
|
|
3444
|
+
done: false
|
|
3445
|
+
};
|
|
3446
|
+
cleanup();
|
|
3447
|
+
return {
|
|
3448
|
+
value: void 0,
|
|
3449
|
+
done: true
|
|
3450
|
+
};
|
|
3451
|
+
},
|
|
3452
|
+
async return() {
|
|
3453
|
+
buffered.length = 0;
|
|
3454
|
+
cleanup();
|
|
3455
|
+
return {
|
|
3456
|
+
value: void 0,
|
|
3457
|
+
done: true
|
|
3458
|
+
};
|
|
3459
|
+
},
|
|
3460
|
+
[Symbol.asyncIterator]() {
|
|
3461
|
+
return iterator;
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
3464
|
+
return iterator;
|
|
3465
|
+
}
|
|
3466
|
+
/** This client's id on the server, assigned in the handshake; 0 before it. */
|
|
3467
|
+
get clientID() {
|
|
3468
|
+
return asClientID(this.#clid);
|
|
3469
|
+
}
|
|
3470
|
+
/**
|
|
3471
|
+
* What the connected server is: version, generation, and the uid hash it
|
|
3472
|
+
* uses. Read from `initserver`; before connecting the family is `unknown`
|
|
3473
|
+
* and the uid hash SHA-1.
|
|
3474
|
+
*/
|
|
3475
|
+
get serverCapabilities() {
|
|
3476
|
+
return this.#capabilities;
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Our own `client_unique_identifier`, hashed with the algorithm the connected
|
|
3480
|
+
* server uses.
|
|
3481
|
+
*
|
|
3482
|
+
* TS3 uses SHA-1 and TS6 SHA-256, so this reads the algorithm learned during
|
|
3483
|
+
* initserver rather than assuming one. Comparing a hard-coded SHA-1 UID to a
|
|
3484
|
+
* TS6 server silently never matches.
|
|
3485
|
+
*/
|
|
3486
|
+
get uid() {
|
|
3487
|
+
return this.#identity.uid(this.#capabilities.uidAlgorithm);
|
|
3488
|
+
}
|
|
3489
|
+
/** The channel this client is in, as it last learned; 0 before connecting. */
|
|
3490
|
+
get channelID() {
|
|
3491
|
+
return this.#clients.get(this.#clid)?.channelID ?? asChannelID(0n);
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* Send Opus voice directly to specific clients, bypassing channel routing.
|
|
3495
|
+
* Throws a NotConnectedError unless the client is connected.
|
|
3496
|
+
*/
|
|
3497
|
+
sendWhisper(data, targetClientIDs, codec) {
|
|
3498
|
+
if (!this.#canSend(false)) throw new NotConnectedError();
|
|
3499
|
+
this.#handler.sendWhisperPacket(data, [...targetClientIDs], codec);
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* Send one encoded audio frame to the channel this client is in. Throws a
|
|
3503
|
+
* NotConnectedError unless the client is connected.
|
|
3504
|
+
*/
|
|
3505
|
+
sendVoice(data, codec) {
|
|
3506
|
+
if (!this.#canSend(false)) throw new NotConnectedError();
|
|
3507
|
+
this.#handler.sendVoicePacket(data, codec);
|
|
3508
|
+
}
|
|
3509
|
+
/**
|
|
3510
|
+
* Upload `data` over a negotiated transfer, through the client's
|
|
3511
|
+
* `fileTransferSocketFactory` if it has one.
|
|
3512
|
+
*/
|
|
3513
|
+
uploadFileData(host, info, data, options = {}) {
|
|
3514
|
+
return uploadFileData(host, info, data, {
|
|
3515
|
+
...options,
|
|
3516
|
+
socketFactory: this.#fileTransferSocketFactory
|
|
3517
|
+
});
|
|
3518
|
+
}
|
|
3519
|
+
/**
|
|
3520
|
+
* Download a negotiated transfer into `dest`, through the client's
|
|
3521
|
+
* `fileTransferSocketFactory` if it has one.
|
|
3522
|
+
*/
|
|
3523
|
+
downloadFileData(host, info, dest, options = {}) {
|
|
3524
|
+
return downloadFileData(host, info, dest, {
|
|
3525
|
+
...options,
|
|
3526
|
+
socketFactory: this.#fileTransferSocketFactory
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3529
|
+
#markConnected() {
|
|
3530
|
+
if (this.#status !== ClientStatus.Connecting || this.#attempt.settled) return;
|
|
3531
|
+
this.#status = ClientStatus.Connected;
|
|
3532
|
+
this.#attempt.resolve();
|
|
3533
|
+
this.#emit("connected", void 0);
|
|
3534
|
+
}
|
|
3535
|
+
#newHandler() {
|
|
3536
|
+
const handler = new PacketHandler(this.#crypt, this.#logger, this.#socketFactory);
|
|
3537
|
+
handler.onPacket = (p) => {
|
|
3538
|
+
if (handler === this.#handler) this.#handlePacket(p);
|
|
3539
|
+
};
|
|
3540
|
+
handler.onClosed = (err) => {
|
|
3541
|
+
if (handler === this.#handler) this.#handleConnectionClosed(err);
|
|
3542
|
+
};
|
|
3543
|
+
handler.onReceiveError = (error) => {
|
|
3544
|
+
if (handler === this.#handler) this.#handleReceiveError(error);
|
|
3545
|
+
};
|
|
3546
|
+
return handler;
|
|
3547
|
+
}
|
|
3548
|
+
#resetForConnect() {
|
|
3549
|
+
this.#handler.close();
|
|
3550
|
+
this.#crypt = new Crypt(this.#identity);
|
|
3551
|
+
this.#handler = this.#newHandler();
|
|
3552
|
+
this.#attempt = newConnectAttempt();
|
|
3553
|
+
this.#cmdTrack.reset(new NotConnectedError());
|
|
3554
|
+
this.#clients.clear();
|
|
3555
|
+
this.#clid = 0;
|
|
3556
|
+
this.#capabilities = unknownServerCapabilities();
|
|
3557
|
+
}
|
|
3558
|
+
async #resolveAddr(signal) {
|
|
3559
|
+
const { host, port } = splitHostPort(this.#addr);
|
|
3560
|
+
const fallback = joinHostPort(host, port);
|
|
3561
|
+
const budget = new AbortController();
|
|
3562
|
+
const timer = setTimeout(() => budget.abort(new TeamspeakError("address resolution took too long")), RESOLVE_BUDGET_MS);
|
|
3563
|
+
const lookup = AbortSignal.any([signal, budget.signal]);
|
|
3564
|
+
try {
|
|
3565
|
+
return (await abortable(this.#resolver.resolve(this.#addr, { signal: lookup }), lookup))[0]?.addr ?? fallback;
|
|
3566
|
+
} catch (error) {
|
|
3567
|
+
signal.throwIfAborted();
|
|
3568
|
+
this.#logger.warn("address resolution failed, falling back to direct", { error: String(budget.signal.aborted ? budget.signal.reason : error) });
|
|
3569
|
+
return fallback;
|
|
3570
|
+
} finally {
|
|
3571
|
+
clearTimeout(timer);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
/**
|
|
3575
|
+
* A datagram from the server could not be handled. During the handshake that
|
|
3576
|
+
* is the connection failing, reported as a CryptoInitError with the cause;
|
|
3577
|
+
* once connected, one bad packet is not worth the session, so it is dropped.
|
|
3578
|
+
*/
|
|
3579
|
+
#handleReceiveError(error) {
|
|
3580
|
+
if (this.#status !== ClientStatus.Connected) {
|
|
3581
|
+
this.#failConnection(error instanceof CryptoInitError ? error : new CryptoInitError("the handshake failed on a malformed packet", { cause: error }));
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
this.#logger.debug("dropped a malformed packet", { error: String(error) });
|
|
3585
|
+
}
|
|
3586
|
+
#handlePacket(p) {
|
|
3587
|
+
const pType = p.typeFlagged & 15;
|
|
3588
|
+
if (pType === 8) {
|
|
3589
|
+
if (this.#crypt.cryptoInitComplete) return;
|
|
3590
|
+
const response = processInit1(this.#crypt, p.data);
|
|
3591
|
+
if (response) this.#handler.sendPacket(PacketType.Init1, response, 0);
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
if ((pType === 0 || pType === 1) && p.data.length > 5) {
|
|
3595
|
+
this.#handleVoicePacket(p.data);
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
if ((pType === 2 || pType === 3) && p.data.length > 0) this.#handleCommandLines(Buffer.from(p.data).toString("utf8"));
|
|
3599
|
+
}
|
|
3600
|
+
/**
|
|
3601
|
+
* Parse an incoming S2C voice packet.
|
|
3602
|
+
* Format: [VId: u16] [CId: u16] [Codec: u8] [Data: var]
|
|
3603
|
+
*/
|
|
3604
|
+
#handleVoicePacket(payload) {
|
|
3605
|
+
if (!this.#listeners.get("voiceData")?.length && this.#eventMiddlewares.length === 0) return;
|
|
3606
|
+
if (this.#queue.length > MAX_PENDING_DELIVERIES) {
|
|
3607
|
+
this.#dropVoice();
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
const clientId = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint16(2, false);
|
|
3611
|
+
if (clientId === this.#clid) return;
|
|
3612
|
+
const data = payload.slice(5);
|
|
3613
|
+
this.#emitServerEvent({
|
|
3614
|
+
name: "voiceData",
|
|
3615
|
+
payload: {
|
|
3616
|
+
clientID: asClientID(clientId),
|
|
3617
|
+
codec: payload[4],
|
|
3618
|
+
data
|
|
3619
|
+
}
|
|
3620
|
+
});
|
|
3621
|
+
}
|
|
3622
|
+
#handleCommandLines(s) {
|
|
3623
|
+
if (!s) return;
|
|
3624
|
+
const lines = s.split("\0").join("\n").split("\n");
|
|
3625
|
+
for (const line of lines) {
|
|
3626
|
+
const trimmed = line.replace(/\r$/, "");
|
|
3627
|
+
if (!trimmed) continue;
|
|
3628
|
+
for (const row of splitCommandRows(trimmed)) this.#handleCommandStr(row);
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
#handleCommandStr(s) {
|
|
3632
|
+
const cmd = parseCommand(s);
|
|
3633
|
+
if (!cmd) return;
|
|
3634
|
+
if (!this.#crypt.cryptoInitComplete && !PRE_INIT_COMMANDS.has(cmd.name)) {
|
|
3635
|
+
this.#logger.debug("dropped a command that arrived before crypto init", { command: cmd.name });
|
|
3636
|
+
return;
|
|
3637
|
+
}
|
|
3638
|
+
if (packetChannel.hasSubscribers) packetChannel.publish({
|
|
3639
|
+
command: cmd.name,
|
|
3640
|
+
client: this
|
|
3641
|
+
});
|
|
3642
|
+
if (cmd.name === "") {
|
|
3643
|
+
if (Object.keys(cmd.params).length > 0) this.#cmdTrack.buffer(cmd.params);
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
if (cmd.name === "channellistfinished") {
|
|
3647
|
+
this.#cmdTrack.signalWelcomeComplete();
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
if (cmd.name.startsWith("notify")) {
|
|
3651
|
+
if (this.#cmdTrack.expects(cmd.name)) {
|
|
3652
|
+
this.#cmdTrack.buffer(cmd.params, cmd.name);
|
|
3653
|
+
this.#cmdTrack.offerCorrelated(cmd.name, cmd.params);
|
|
3654
|
+
}
|
|
3655
|
+
const result = handleNotification(cmd, this.#clid, this.#clients, this.#nickname);
|
|
3656
|
+
this.#processNotificationResult(result);
|
|
3657
|
+
if (this.#listeners.get("notification")?.length || this.#eventMiddlewares.length > 0) this.#emitServerEvent({
|
|
3658
|
+
name: "notification",
|
|
3659
|
+
payload: {
|
|
3660
|
+
name: cmd.name,
|
|
3661
|
+
params: { ...cmd.params }
|
|
3662
|
+
}
|
|
3663
|
+
});
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
switch (cmd.name) {
|
|
3667
|
+
case "clientinitiv":
|
|
3668
|
+
if (!this.#crypt.cryptoInitComplete) handleHandshakeInitIV(this.#internals, cmd.params);
|
|
3669
|
+
break;
|
|
3670
|
+
case "initivexpand2":
|
|
3671
|
+
if (!this.#crypt.cryptoInitComplete) handleHandshakeExpand2(this.#internals, cmd.params);
|
|
3672
|
+
break;
|
|
3673
|
+
case "initserver":
|
|
3674
|
+
if (this.#status === ClientStatus.Connecting && this.#crypt.cryptoInitComplete) handleInitServer(this.#internals, cmd.params);
|
|
3675
|
+
break;
|
|
3676
|
+
case "error":
|
|
3677
|
+
this.#handleError(cmd.params);
|
|
3678
|
+
break;
|
|
3679
|
+
default: this.#cmdTrack.buffer(cmd.params, cmd.name);
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
#handleError(params) {
|
|
3683
|
+
const { err, rc } = parseServerError(params);
|
|
3684
|
+
if (rc !== null) this.#cmdTrack.resolve(rc, err);
|
|
3685
|
+
else this.#cmdTrack.discardBuffer();
|
|
3686
|
+
if (err && this.#status !== ClientStatus.Connected) {
|
|
3687
|
+
this.#failConnection(err);
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
if (err?.code === ErrorCode.ConnectFailedBanned) {
|
|
3691
|
+
this.#logger.warn("banned by the server, closing the connection");
|
|
3692
|
+
setImmediate(() => this.disconnect().catch(() => {}));
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
#processNotificationResult(result) {
|
|
3696
|
+
switch (result.kind) {
|
|
3697
|
+
case "clientEnter": {
|
|
3698
|
+
const info = result.info;
|
|
3699
|
+
if (info.id === this.#clid) this.#nickname = info.nickname;
|
|
3700
|
+
else if (this.#clid === 0 && isAutoNicknameMatch(this.#nickname, info.nickname)) {
|
|
3701
|
+
this.#clid = info.id;
|
|
3702
|
+
this.#handler.setClientID(info.id);
|
|
3703
|
+
this.#nickname = info.nickname;
|
|
3704
|
+
}
|
|
3705
|
+
this.#emitServerEvent({
|
|
3706
|
+
name: "clientEnter",
|
|
3707
|
+
payload: info
|
|
3708
|
+
});
|
|
3709
|
+
break;
|
|
3710
|
+
}
|
|
3711
|
+
case "clientLeave": {
|
|
3712
|
+
const event = result.event;
|
|
3713
|
+
this.#emitServerEvent({
|
|
3714
|
+
name: "clientLeave",
|
|
3715
|
+
payload: event
|
|
3716
|
+
});
|
|
3717
|
+
if (!result.isSelf) break;
|
|
3718
|
+
if (event.reason === Reason.KickServer || event.reason === Reason.KickServerBan) this.#emit("kicked", kickedEvent(event));
|
|
3719
|
+
this.#removedByServer(new RemovedError(event.reason, event.reasonMessage, event.invokerName));
|
|
3720
|
+
break;
|
|
3721
|
+
}
|
|
3722
|
+
case "clientMoved":
|
|
3723
|
+
this.#emitServerEvent({
|
|
3724
|
+
name: "clientMoved",
|
|
3725
|
+
payload: result.event
|
|
3726
|
+
});
|
|
3727
|
+
if (result.event.clientID === this.#clid && result.event.reason === Reason.KickChannel) this.#emit("kicked", kickedEvent({
|
|
3728
|
+
...result.event,
|
|
3729
|
+
reasonMessage: result.reasonMessage
|
|
3730
|
+
}));
|
|
3731
|
+
break;
|
|
3732
|
+
case "textMessage":
|
|
3733
|
+
this.#emitServerEvent({
|
|
3734
|
+
name: "textMessage",
|
|
3735
|
+
payload: result.message
|
|
3736
|
+
});
|
|
3737
|
+
break;
|
|
3738
|
+
case "poked": this.#emitServerEvent({
|
|
3739
|
+
name: "poked",
|
|
3740
|
+
payload: result.event
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
/**
|
|
3745
|
+
* End a session the server has ended. The server stops answering a client it
|
|
3746
|
+
* has removed, so waiting for the transport to notice reported the end a
|
|
3747
|
+
* minute late, without its cause, and with the client claiming to be
|
|
3748
|
+
* connected meanwhile.
|
|
3749
|
+
*/
|
|
3750
|
+
#removedByServer(cause) {
|
|
3751
|
+
this.#handleConnectionClosed(cause);
|
|
3752
|
+
this.#handler.close();
|
|
3753
|
+
}
|
|
3754
|
+
/**
|
|
3755
|
+
* Count a dropped voice delivery, and report the drops now and then: at most
|
|
3756
|
+
* once every ten seconds, with how many were dropped since the last report.
|
|
3757
|
+
*/
|
|
3758
|
+
#dropVoice() {
|
|
3759
|
+
this.#voiceDropped++;
|
|
3760
|
+
const now = Date.now();
|
|
3761
|
+
if (now - this.#voiceDropWarnedAt < VOICE_DROP_WARN_INTERVAL_MS) return;
|
|
3762
|
+
this.#voiceDropWarnedAt = now;
|
|
3763
|
+
this.#logger.warn("voice dropped: listeners are not keeping up", { dropped: this.#voiceDropped });
|
|
3764
|
+
this.#voiceDropped = 0;
|
|
3765
|
+
}
|
|
3766
|
+
/** Queue a server event, through the event middleware. */
|
|
3767
|
+
#emitServerEvent(event) {
|
|
3768
|
+
this.#enqueue(() => this.#eventChain(event));
|
|
3769
|
+
}
|
|
3770
|
+
/**
|
|
3771
|
+
* Queue `disconnected`; the promise settles once it has been delivered, and
|
|
3772
|
+
* a connect() waits for it until then.
|
|
3773
|
+
*/
|
|
3774
|
+
#announceDisconnected(cause) {
|
|
3775
|
+
const delivered = new Promise((resolve) => this.#enqueue(() => {
|
|
3776
|
+
try {
|
|
3777
|
+
this.#deliver("disconnected", cause);
|
|
3778
|
+
} finally {
|
|
3779
|
+
resolve();
|
|
3780
|
+
}
|
|
3781
|
+
}));
|
|
3782
|
+
this.#disconnectedDelivered = delivered;
|
|
3783
|
+
delivered.then(() => {
|
|
3784
|
+
if (this.#disconnectedDelivered === delivered) this.#disconnectedDelivered = void 0;
|
|
3785
|
+
});
|
|
3786
|
+
return delivered;
|
|
3787
|
+
}
|
|
3788
|
+
/** Queue a lifecycle event, which middleware does not see. */
|
|
3789
|
+
#emit(event, payload) {
|
|
3790
|
+
this.#enqueue(() => this.#deliver(event, payload));
|
|
3791
|
+
}
|
|
3792
|
+
#deliver(event, payload) {
|
|
3793
|
+
for (const subscription of this.#listeners.get(event) ?? []) {
|
|
3794
|
+
if (subscription.removed) continue;
|
|
3795
|
+
try {
|
|
3796
|
+
subscription.listener(payload);
|
|
3797
|
+
} catch (error) {
|
|
3798
|
+
queueMicrotask(() => {
|
|
3799
|
+
throw error;
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
/**
|
|
3805
|
+
* Run deliveries one at a time, in order, once the packet being handled is
|
|
3806
|
+
* done with. A listener that throws does so as an uncaught exception, as it
|
|
3807
|
+
* would from an EventEmitter, and the listeners and deliveries behind it
|
|
3808
|
+
* still run.
|
|
3809
|
+
*/
|
|
3810
|
+
#enqueue(delivery) {
|
|
3811
|
+
this.#queue.push(delivery);
|
|
3812
|
+
if (this.#draining) return;
|
|
3813
|
+
this.#draining = true;
|
|
3814
|
+
setImmediate(this.#drain);
|
|
3815
|
+
}
|
|
3816
|
+
#drain = () => {
|
|
3817
|
+
try {
|
|
3818
|
+
while (this.#queue.length > 0) this.#queue.shift()();
|
|
3819
|
+
} finally {
|
|
3820
|
+
if (this.#queue.length > 0) setImmediate(this.#drain);
|
|
3821
|
+
else this.#draining = false;
|
|
3822
|
+
}
|
|
3823
|
+
};
|
|
3824
|
+
#handleConnectionClosed(err) {
|
|
3825
|
+
if (this.#status === ClientStatus.Disconnected) return;
|
|
3826
|
+
this.#cmdTrack.reset(new NotConnectedError(err ? { cause: err } : void 0));
|
|
3827
|
+
if (this.#status !== ClientStatus.Connected) {
|
|
3828
|
+
this.#failConnection(err ?? new TeamspeakError("connection closed before the TeamSpeak handshake completed"));
|
|
3829
|
+
return;
|
|
3830
|
+
}
|
|
3831
|
+
this.#status = ClientStatus.Disconnected;
|
|
3832
|
+
const cause = err ?? new TeamspeakError("connection closed");
|
|
3833
|
+
this.#lastDrop = cause;
|
|
3834
|
+
this.#announceDisconnected(cause);
|
|
3835
|
+
this.#scheduleReconnect(cause).catch((error) => {
|
|
3836
|
+
this.#logger.error("reconnecting failed", { error: String(error) });
|
|
3837
|
+
});
|
|
3838
|
+
}
|
|
3839
|
+
/**
|
|
3840
|
+
* Retry a dropped connection according to the reconnect policy.
|
|
3841
|
+
*
|
|
3842
|
+
* Never runs after an explicit disconnect, and never more than one at a time:
|
|
3843
|
+
* two failures in quick succession would otherwise start competing loops, and
|
|
3844
|
+
* a loop shorter than the server's antiflood window bans the IP rather than
|
|
3845
|
+
* reconnecting it. A drop while the loop runs is the loop's to retry: it
|
|
3846
|
+
* ends only once its attempt is still connected when it resumes.
|
|
3847
|
+
*/
|
|
3848
|
+
async #scheduleReconnect(err) {
|
|
3849
|
+
const options = this.#reconnectOptions;
|
|
3850
|
+
if (options === void 0 || this.#stopped || this.#reconnecting) return;
|
|
3851
|
+
const policy = resolveBackoffPolicy(options);
|
|
3852
|
+
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
|
3853
|
+
this.#reconnecting = true;
|
|
3854
|
+
const stop = new AbortController();
|
|
3855
|
+
this.#stopReconnecting = stop;
|
|
3856
|
+
try {
|
|
3857
|
+
for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
|
|
3858
|
+
if (this.#stopped) return;
|
|
3859
|
+
let retry;
|
|
3860
|
+
try {
|
|
3861
|
+
retry = shouldRetry(err, attempt);
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
|
|
3864
|
+
return;
|
|
3865
|
+
}
|
|
3866
|
+
if (!retry) {
|
|
3867
|
+
this.#logger.info("not retrying", {
|
|
3868
|
+
attempt,
|
|
3869
|
+
reason: err.message
|
|
3870
|
+
});
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
const delay = backoffDelay(policy, attempt);
|
|
3874
|
+
this.#logger.info("reconnecting", {
|
|
3875
|
+
attempt,
|
|
3876
|
+
delayMs: delay
|
|
3877
|
+
});
|
|
3878
|
+
await sleep(delay, stop.signal);
|
|
3879
|
+
if (this.#stopped) return;
|
|
3880
|
+
if (this.#status !== ClientStatus.Disconnected) {
|
|
3881
|
+
this.#logger.info("reconnect loop ended: connected by other means", { attempt });
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
try {
|
|
3885
|
+
await this.connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) });
|
|
3886
|
+
if (this.status === ClientStatus.Connected) {
|
|
3887
|
+
this.#logger.info("reconnected", { attempt });
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
err = this.#lastDrop ?? new TeamspeakError("connection closed");
|
|
3891
|
+
} catch (retryErr) {
|
|
3892
|
+
if (retryErr instanceof AlreadyConnectedError) return;
|
|
3893
|
+
err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr));
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
|
|
3897
|
+
} finally {
|
|
3898
|
+
this.#reconnecting = false;
|
|
3899
|
+
if (this.#stopReconnecting === stop) this.#stopReconnecting = void 0;
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
/**
|
|
3903
|
+
* A connection attempt has failed for good: settle it with the cause, and
|
|
3904
|
+
* tear the connection down. Does nothing once the handshake has completed,
|
|
3905
|
+
* so it cannot end a live session.
|
|
3906
|
+
*
|
|
3907
|
+
* Without this, a handshake the server rejects (a wrong password, say, which
|
|
3908
|
+
* the server answers with an error instead of closing the socket) surfaces
|
|
3909
|
+
* only as the caller's own timeout, and the client stays in `Connecting`
|
|
3910
|
+
* forever without ever emitting `disconnected`.
|
|
3911
|
+
*/
|
|
3912
|
+
#failConnection(error) {
|
|
3913
|
+
if (this.#status === ClientStatus.Connected) return;
|
|
3914
|
+
this.#attempt.reject(error);
|
|
3915
|
+
if (this.#status === ClientStatus.Disconnected) return;
|
|
3916
|
+
this.#status = ClientStatus.Disconnected;
|
|
3917
|
+
this.#handler.close();
|
|
3918
|
+
this.#announceDisconnected(error instanceof Error ? error : new TeamspeakError(String(error)));
|
|
3919
|
+
}
|
|
3920
|
+
};
|
|
3921
|
+
function kickedEvent(event) {
|
|
3922
|
+
return {
|
|
3923
|
+
reason: event.reason,
|
|
3924
|
+
reasonMessage: event.reasonMessage,
|
|
3925
|
+
invokerName: event.invokerName,
|
|
3926
|
+
invokerUID: event.invokerUID,
|
|
3927
|
+
invokerID: event.invokerID
|
|
3928
|
+
};
|
|
3929
|
+
}
|
|
3930
|
+
//#endregion
|
|
3931
|
+
//#region src/api/voice.ts
|
|
3932
|
+
/** The channel's description, which `listChannels` omits for bandwidth. */
|
|
3933
|
+
async function getChannelDescription(client, cid, options) {
|
|
3934
|
+
return (await client.send({
|
|
3935
|
+
command: "channelgetdescription",
|
|
3936
|
+
params: [["cid", cid]]
|
|
3937
|
+
}, options))[0]?.["channel_description"] ?? "";
|
|
3938
|
+
}
|
|
3939
|
+
/**
|
|
3940
|
+
* Subscribe to a channel: receive its clients' events and voice without
|
|
3941
|
+
* joining it.
|
|
3942
|
+
*/
|
|
3943
|
+
async function subscribeChannel(client, cid, options) {
|
|
3944
|
+
await client.send({
|
|
3945
|
+
command: "channelsubscribe",
|
|
3946
|
+
params: [["cid", cid]]
|
|
3947
|
+
}, options);
|
|
3948
|
+
}
|
|
3949
|
+
/** Stop receiving a subscribed channel's events and voice. */
|
|
3950
|
+
async function unsubscribeChannel(client, cid, options) {
|
|
3951
|
+
await client.send({
|
|
3952
|
+
command: "channelunsubscribe",
|
|
3953
|
+
params: [["cid", cid]]
|
|
3954
|
+
}, options);
|
|
3955
|
+
}
|
|
3956
|
+
/** Subscribe to every channel on the server. */
|
|
3957
|
+
async function subscribeAllChannels(client, options) {
|
|
3958
|
+
await client.send({ command: "channelsubscribeall" }, options);
|
|
3959
|
+
}
|
|
3960
|
+
/** Unsubscribe from every channel on the server. */
|
|
3961
|
+
async function unsubscribeAllChannels(client, options) {
|
|
3962
|
+
await client.send({ command: "channelunsubscribeall" }, options);
|
|
3963
|
+
}
|
|
3964
|
+
//#endregion
|
|
3965
|
+
export { AlreadyConnectedError, Client, ClientStatus, ConnectionTimeoutError, CryptoInitError, FileTransferError, Identity, InvalidAddressError, InvalidIdentityError, RemovedError, backoffDelay, commandChannel, defaultBackoffPolicy, defaultShouldRetry, dialFileTransfer, downloadFileData, generateIdentity, generateIdentitySync, getChannelDescription, packetChannel, parseIdentity, subscribeAllChannels, subscribeChannel, uidFromPublicKey, unknownServerCapabilities, unsubscribeAllChannels, unsubscribeChannel, uploadFileData };
|
|
3966
|
+
|
|
3967
|
+
//# sourceMappingURL=index.js.map
|