@honeybbq/teamspeak-client 1.0.0-beta.3 → 1.0.0-beta.4
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 +60 -7
- package/dist/capabilities.d.ts +13 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/client.d.ts +17 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/discovery/resolver.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/handshake/crypt-handshake.d.ts +9 -1
- package/dist/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/handshake.d.ts.map +1 -1
- package/dist/hooks.d.ts +7 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +762 -185
- package/dist/index.js.map +1 -1
- package/dist/trace.d.ts +223 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/transfer.d.ts +80 -1
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transport/handler.d.ts +2 -0
- package/dist/transport/handler.d.ts.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -4
- package/src/capabilities.ts +23 -2
- package/src/client.ts +390 -52
- package/src/crypto/crypt.ts +5 -5
- package/src/discovery/resolver.ts +56 -3
- package/src/errors.ts +92 -11
- package/src/handshake/crypt-handshake.ts +26 -5
- package/src/handshake/crypt-init2.ts +5 -5
- package/src/handshake.ts +3 -1
- package/src/hooks.ts +30 -0
- package/src/index.ts +30 -2
- package/src/throttle.ts +2 -2
- package/src/trace.ts +238 -0
- package/src/transfer.ts +334 -25
- package/src/transport/handler.ts +13 -9
- package/src/transport/packet.ts +1 -1
- package/src/types.ts +13 -0
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { CommandTimeoutError, ErrorCode, NotConnectedError, Reason, ServerError, TeamspeakError, asChannelID, asClientID, asUID, noopLogger } from "@honeybbq/teamspeak-core";
|
|
1
|
+
import { CommandTimeoutError, ErrorCode, NotConnectedError, Reason, ServerError, TeamspeakError, asChannelID, asClientID, asServerID, asUID, noopLogger } from "@honeybbq/teamspeak-core";
|
|
2
2
|
import { createCipheriv, createECDH, createHash, createPrivateKey, createPublicKey, createSign, createVerify, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { buildCommand, buildCommandOrdered, encodeRequest, parseClient, parseCommand, parseErrorCode, parseIconID, parseInt10, parseServerError, parseUint16, parseUint64, responseSpec, sleep, splitCommandRows } from "@honeybbq/teamspeak-core/internal";
|
|
3
4
|
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
4
5
|
import { lookup, resolveSrv } from "node:dns/promises";
|
|
5
6
|
import { connect, createConnection, isIP } from "node:net";
|
|
6
7
|
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
8
|
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { Transform } from "node:stream";
|
|
9
10
|
import { channel, tracingChannel } from "node:diagnostics_channel";
|
|
10
11
|
export * from "@honeybbq/teamspeak-core";
|
|
11
12
|
/** Where a client is in its connection's life. */
|
|
@@ -17,15 +18,56 @@ var ClientStatus = /* @__PURE__ */ Object.freeze({
|
|
|
17
18
|
/** Connected: the handshake completed and commands can be sent. */
|
|
18
19
|
Connected: "connected"
|
|
19
20
|
});
|
|
21
|
+
/** Where a connection attempt can fail; see {@link AttemptFailedInfo}. */
|
|
22
|
+
var ConnectPhase = /* @__PURE__ */ Object.freeze({
|
|
23
|
+
/** Opening the socket, or the address was not usable once resolved. */
|
|
24
|
+
Open: "open",
|
|
25
|
+
/** The handshake, from the first packet until the shared secret exists. */
|
|
26
|
+
Handshake: "handshake",
|
|
27
|
+
/** After the handshake, waiting for the server to accept the client: a wrong password, a ban or an invalid nickname end here. */
|
|
28
|
+
Join: "join"
|
|
29
|
+
});
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/hooks.ts
|
|
32
|
+
/**
|
|
33
|
+
* Report an error from a trace hook the way a throwing event listener's is
|
|
34
|
+
* reported: rethrown on its own, as an uncaught exception.
|
|
35
|
+
*/
|
|
36
|
+
function reportHookError(error) {
|
|
37
|
+
queueMicrotask(() => {
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Call a trace hook, if there is one, and never let it touch what called it:
|
|
43
|
+
* a throw, or the rejection of a promise it returns, is reported as a
|
|
44
|
+
* throwing listener's is. The info is built only when there is a hook.
|
|
45
|
+
*/
|
|
46
|
+
function callHook(hook, owner, info) {
|
|
47
|
+
if (hook === void 0) return;
|
|
48
|
+
try {
|
|
49
|
+
const result = hook.call(owner, info());
|
|
50
|
+
if (result !== null && typeof result === "object" && "then" in result) Promise.resolve(result).then(void 0, reportHookError);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
reportHookError(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
20
55
|
//#endregion
|
|
21
56
|
//#region src/errors.ts
|
|
22
57
|
/** `connect()` was called on a client that is connecting or connected. */
|
|
23
58
|
var AlreadyConnectedError = class extends TeamspeakError {
|
|
24
59
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
25
60
|
static kind = "AlreadyConnectedError";
|
|
61
|
+
static {
|
|
62
|
+
Object.defineProperty(this.prototype, "name", {
|
|
63
|
+
value: "AlreadyConnectedError",
|
|
64
|
+
writable: true,
|
|
65
|
+
configurable: true,
|
|
66
|
+
enumerable: false
|
|
67
|
+
});
|
|
68
|
+
}
|
|
26
69
|
constructor() {
|
|
27
70
|
super("already connecting or connected");
|
|
28
|
-
this.name = "AlreadyConnectedError";
|
|
29
71
|
}
|
|
30
72
|
};
|
|
31
73
|
/**
|
|
@@ -39,11 +81,18 @@ var AlreadyConnectedError = class extends TeamspeakError {
|
|
|
39
81
|
var InvalidAddressError = class extends TeamspeakError {
|
|
40
82
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
41
83
|
static kind = "InvalidAddressError";
|
|
84
|
+
static {
|
|
85
|
+
Object.defineProperty(this.prototype, "name", {
|
|
86
|
+
value: "InvalidAddressError",
|
|
87
|
+
writable: true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
enumerable: false
|
|
90
|
+
});
|
|
91
|
+
}
|
|
42
92
|
/** The address as given. */
|
|
43
93
|
address;
|
|
44
94
|
constructor(address, reason) {
|
|
45
95
|
super(`invalid address ${JSON.stringify(address)}: ${reason}`);
|
|
46
|
-
this.name = "InvalidAddressError";
|
|
47
96
|
this.address = address;
|
|
48
97
|
}
|
|
49
98
|
};
|
|
@@ -54,9 +103,16 @@ var InvalidAddressError = class extends TeamspeakError {
|
|
|
54
103
|
var EAXTagMismatchError = class extends TeamspeakError {
|
|
55
104
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
56
105
|
static kind = "EAXTagMismatchError";
|
|
106
|
+
static {
|
|
107
|
+
Object.defineProperty(this.prototype, "name", {
|
|
108
|
+
value: "EAXTagMismatchError",
|
|
109
|
+
writable: true,
|
|
110
|
+
configurable: true,
|
|
111
|
+
enumerable: false
|
|
112
|
+
});
|
|
113
|
+
}
|
|
57
114
|
constructor() {
|
|
58
115
|
super("EAX tag mismatch");
|
|
59
|
-
this.name = "EAXTagMismatchError";
|
|
60
116
|
}
|
|
61
117
|
};
|
|
62
118
|
/**
|
|
@@ -66,18 +122,32 @@ var EAXTagMismatchError = class extends TeamspeakError {
|
|
|
66
122
|
var FakeSignatureMismatchError = class extends TeamspeakError {
|
|
67
123
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
68
124
|
static kind = "FakeSignatureMismatchError";
|
|
125
|
+
static {
|
|
126
|
+
Object.defineProperty(this.prototype, "name", {
|
|
127
|
+
value: "FakeSignatureMismatchError",
|
|
128
|
+
writable: true,
|
|
129
|
+
configurable: true,
|
|
130
|
+
enumerable: false
|
|
131
|
+
});
|
|
132
|
+
}
|
|
69
133
|
constructor() {
|
|
70
134
|
super("fake signature mismatch");
|
|
71
|
-
this.name = "FakeSignatureMismatchError";
|
|
72
135
|
}
|
|
73
136
|
};
|
|
74
137
|
/** A file transfer connection failed: it could not be opened, or broke during the transfer. */
|
|
75
138
|
var FileTransferError = class extends TeamspeakError {
|
|
76
139
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
77
140
|
static kind = "FileTransferError";
|
|
141
|
+
static {
|
|
142
|
+
Object.defineProperty(this.prototype, "name", {
|
|
143
|
+
value: "FileTransferError",
|
|
144
|
+
writable: true,
|
|
145
|
+
configurable: true,
|
|
146
|
+
enumerable: false
|
|
147
|
+
});
|
|
148
|
+
}
|
|
78
149
|
constructor(message, options) {
|
|
79
150
|
super(message, options);
|
|
80
|
-
this.name = "FileTransferError";
|
|
81
151
|
}
|
|
82
152
|
};
|
|
83
153
|
/**
|
|
@@ -85,21 +155,35 @@ var FileTransferError = class extends TeamspeakError {
|
|
|
85
155
|
* Init1 step, public key, license or proof. `connect()` rejects with it, the
|
|
86
156
|
* underlying failure as its `cause`.
|
|
87
157
|
*/
|
|
88
|
-
var
|
|
158
|
+
var HandshakeError = class extends TeamspeakError {
|
|
89
159
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
90
|
-
static kind = "
|
|
160
|
+
static kind = "HandshakeError";
|
|
161
|
+
static {
|
|
162
|
+
Object.defineProperty(this.prototype, "name", {
|
|
163
|
+
value: "HandshakeError",
|
|
164
|
+
writable: true,
|
|
165
|
+
configurable: true,
|
|
166
|
+
enumerable: false
|
|
167
|
+
});
|
|
168
|
+
}
|
|
91
169
|
constructor(message, options) {
|
|
92
170
|
super(message, options);
|
|
93
|
-
this.name = "CryptoInitError";
|
|
94
171
|
}
|
|
95
172
|
};
|
|
96
173
|
/** An identity string `parseIdentity()` cannot read. */
|
|
97
174
|
var InvalidIdentityError = class extends TeamspeakError {
|
|
98
175
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
99
176
|
static kind = "InvalidIdentityError";
|
|
177
|
+
static {
|
|
178
|
+
Object.defineProperty(this.prototype, "name", {
|
|
179
|
+
value: "InvalidIdentityError",
|
|
180
|
+
writable: true,
|
|
181
|
+
configurable: true,
|
|
182
|
+
enumerable: false
|
|
183
|
+
});
|
|
184
|
+
}
|
|
100
185
|
constructor(message = "invalid identity format", options) {
|
|
101
186
|
super(message, options);
|
|
102
|
-
this.name = "InvalidIdentityError";
|
|
103
187
|
}
|
|
104
188
|
};
|
|
105
189
|
/**
|
|
@@ -109,9 +193,16 @@ var InvalidIdentityError = class extends TeamspeakError {
|
|
|
109
193
|
var ConnectionTimeoutError = class extends TeamspeakError {
|
|
110
194
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
111
195
|
static kind = "ConnectionTimeoutError";
|
|
196
|
+
static {
|
|
197
|
+
Object.defineProperty(this.prototype, "name", {
|
|
198
|
+
value: "ConnectionTimeoutError",
|
|
199
|
+
writable: true,
|
|
200
|
+
configurable: true,
|
|
201
|
+
enumerable: false
|
|
202
|
+
});
|
|
203
|
+
}
|
|
112
204
|
constructor() {
|
|
113
205
|
super("connection timed out: the server stopped answering");
|
|
114
|
-
this.name = "ConnectionTimeoutError";
|
|
115
206
|
}
|
|
116
207
|
};
|
|
117
208
|
/**
|
|
@@ -122,6 +213,14 @@ var ConnectionTimeoutError = class extends TeamspeakError {
|
|
|
122
213
|
var RemovedError = class extends TeamspeakError {
|
|
123
214
|
/** {@inheritDoc TeamspeakError.kind} */
|
|
124
215
|
static kind = "RemovedError";
|
|
216
|
+
static {
|
|
217
|
+
Object.defineProperty(this.prototype, "name", {
|
|
218
|
+
value: "RemovedError",
|
|
219
|
+
writable: true,
|
|
220
|
+
configurable: true,
|
|
221
|
+
enumerable: false
|
|
222
|
+
});
|
|
223
|
+
}
|
|
125
224
|
/** Why: `Reason.KickServer`, `KickServerBan`, `ServerStop` or `ServerShutdown`. */
|
|
126
225
|
reason;
|
|
127
226
|
/** The reason the server or the invoker gave, if any. */
|
|
@@ -133,7 +232,6 @@ var RemovedError = class extends TeamspeakError {
|
|
|
133
232
|
if (invokerName) message += ` by ${invokerName}`;
|
|
134
233
|
if (reasonMessage) message += `: ${reasonMessage}`;
|
|
135
234
|
super(message);
|
|
136
|
-
this.name = "RemovedError";
|
|
137
235
|
this.reason = reason;
|
|
138
236
|
this.reasonMessage = reasonMessage;
|
|
139
237
|
this.invokerName = invokerName;
|
|
@@ -149,6 +247,107 @@ function reasonName(reason) {
|
|
|
149
247
|
}
|
|
150
248
|
}
|
|
151
249
|
//#endregion
|
|
250
|
+
//#region src/handshake/crypt-handshake.ts
|
|
251
|
+
var INIT_VERSION = 1566914096;
|
|
252
|
+
var INIT_STEP_LEN = 21;
|
|
253
|
+
/** The RSA challenge each connection attempt, by its Crypt, has answered. */
|
|
254
|
+
var answeredChallenges = /* @__PURE__ */ new WeakMap();
|
|
255
|
+
/**
|
|
256
|
+
* The RSA puzzle `crypt`'s attempt solved: the level the server demanded and
|
|
257
|
+
* the milliseconds spent on it. Level 0 and no time when the server sent none.
|
|
258
|
+
*/
|
|
259
|
+
function puzzleOf(crypt) {
|
|
260
|
+
const answered = answeredChallenges.get(crypt);
|
|
261
|
+
return {
|
|
262
|
+
level: answered?.level ?? 0,
|
|
263
|
+
durationMs: answered?.durationMs ?? 0
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Handle the TS3INIT1 handshake steps.
|
|
268
|
+
* Returns the response bytes to send, or null if nothing should be sent.
|
|
269
|
+
* Throws a HandshakeError for a step that cannot be answered.
|
|
270
|
+
*/
|
|
271
|
+
function processInit1(crypt, data) {
|
|
272
|
+
if (data === null || data.length >= 1 && data[0] === 127) return buildInit1StartPacket();
|
|
273
|
+
if (data.length === 0) throw new HandshakeError("empty Init1 packet");
|
|
274
|
+
switch (data[0]) {
|
|
275
|
+
case 0: return buildInit1Step1Packet(data);
|
|
276
|
+
case 1: return buildInit1Step2Packet(data);
|
|
277
|
+
case 2: return buildInit1Step3Packet(data);
|
|
278
|
+
case 3: return buildInit1Step4Packet(crypt, data);
|
|
279
|
+
default: return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function buildInit1StartPacket() {
|
|
283
|
+
const sendData = /* @__PURE__ */ new Uint8Array(21);
|
|
284
|
+
const view = new DataView(sendData.buffer);
|
|
285
|
+
view.setUint32(0, INIT_VERSION, false);
|
|
286
|
+
sendData[4] = 0;
|
|
287
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
288
|
+
const clampedNow = Math.max(0, Math.min(nowSec, 4294967295));
|
|
289
|
+
view.setUint32(5, clampedNow, false);
|
|
290
|
+
const rng = randomBytes(4);
|
|
291
|
+
sendData.set(rng, 9);
|
|
292
|
+
return sendData;
|
|
293
|
+
}
|
|
294
|
+
function buildInit1Step1Packet(data) {
|
|
295
|
+
if (data.length !== INIT_STEP_LEN) return null;
|
|
296
|
+
const sendData = /* @__PURE__ */ new Uint8Array(21);
|
|
297
|
+
sendData[0] = 1;
|
|
298
|
+
const tsRand = data[9] | data[10] << 8 | data[11] << 16 | data[12] << 24;
|
|
299
|
+
new DataView(sendData.buffer).setUint32(17, tsRand >>> 0, false);
|
|
300
|
+
return sendData;
|
|
301
|
+
}
|
|
302
|
+
function buildInit1Step2Packet(data) {
|
|
303
|
+
if (data.length !== INIT_STEP_LEN) return null;
|
|
304
|
+
const sendData = /* @__PURE__ */ new Uint8Array(25);
|
|
305
|
+
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
306
|
+
sendData[4] = 2;
|
|
307
|
+
sendData.set(data.slice(1, 21), 5);
|
|
308
|
+
return sendData;
|
|
309
|
+
}
|
|
310
|
+
function buildInit1Step3Packet(data) {
|
|
311
|
+
if (data.length !== 25) return null;
|
|
312
|
+
const sendData = /* @__PURE__ */ new Uint8Array(233);
|
|
313
|
+
sendData[0] = 3;
|
|
314
|
+
sendData[64] = 1;
|
|
315
|
+
sendData[128] = 1;
|
|
316
|
+
new DataView(sendData.buffer).setUint32(129, 1, false);
|
|
317
|
+
return sendData;
|
|
318
|
+
}
|
|
319
|
+
function buildInit1Step4Packet(crypt, data) {
|
|
320
|
+
if (data.length !== 233) return null;
|
|
321
|
+
const answered = answeredChallenges.get(crypt);
|
|
322
|
+
if (answered !== void 0) return Buffer.from(answered.challenge).equals(Buffer.from(data)) ? answered.answer : null;
|
|
323
|
+
const level = new DataView(data.buffer, data.byteOffset).getUint32(129, false);
|
|
324
|
+
const solveStarted = performance.now();
|
|
325
|
+
const y = crypt.solveRsaChallenge(data, 1, level);
|
|
326
|
+
const durationMs = performance.now() - solveStarted;
|
|
327
|
+
const alphaB64 = Buffer.from(crypt.newAlpha()).toString("base64");
|
|
328
|
+
const omegaB64 = crypt.identity.publicKey();
|
|
329
|
+
const cmd = buildCommandOrdered("clientinitiv", [
|
|
330
|
+
["alpha", alphaB64],
|
|
331
|
+
["omega", omegaB64],
|
|
332
|
+
["ot", "1"],
|
|
333
|
+
["ip", ""]
|
|
334
|
+
]);
|
|
335
|
+
const cmdBytes = Buffer.from(cmd);
|
|
336
|
+
const sendData = new Uint8Array(301 + cmdBytes.length);
|
|
337
|
+
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
338
|
+
sendData[4] = 4;
|
|
339
|
+
sendData.set(data.slice(1, 233), 5);
|
|
340
|
+
sendData.set(y.slice(0, 64), 237);
|
|
341
|
+
sendData.set(cmdBytes, 301);
|
|
342
|
+
answeredChallenges.set(crypt, {
|
|
343
|
+
challenge: Uint8Array.from(data),
|
|
344
|
+
answer: sendData,
|
|
345
|
+
level,
|
|
346
|
+
durationMs
|
|
347
|
+
});
|
|
348
|
+
return sendData;
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
152
351
|
//#region src/abort.ts
|
|
153
352
|
/** The reason an aborted signal gives, as an error. */
|
|
154
353
|
function abortReason(signal) {
|
|
@@ -665,12 +864,12 @@ var Crypt = class {
|
|
|
665
864
|
return this.#alpha;
|
|
666
865
|
}
|
|
667
866
|
solveRsaChallenge(data, offset, level) {
|
|
668
|
-
if (!Number.isInteger(level) || level < 0 || level > 1e5) throw new
|
|
867
|
+
if (!Number.isInteger(level) || level < 0 || level > 1e5) throw new HandshakeError(`RSA challenge level ${level} out of range`);
|
|
669
868
|
const xBytes = data.slice(offset, offset + 64);
|
|
670
869
|
const nBytes = data.slice(offset + 64, offset + 128);
|
|
671
870
|
let y = bytesToBigInt(xBytes);
|
|
672
871
|
const n = bytesToBigInt(nBytes);
|
|
673
|
-
if (n === 0n) throw new
|
|
872
|
+
if (n === 0n) throw new HandshakeError("RSA challenge modulus is zero");
|
|
674
873
|
for (let i = 0; i < level; i++) y = y * y % n;
|
|
675
874
|
return bigIntToBytes(y, 64);
|
|
676
875
|
}
|
|
@@ -682,7 +881,7 @@ var Crypt = class {
|
|
|
682
881
|
try {
|
|
683
882
|
sharedSecret = this.#getSharedSecret(importPublicKey(omegaBytes));
|
|
684
883
|
} catch (error) {
|
|
685
|
-
throw new
|
|
884
|
+
throw new HandshakeError("the server's public key is invalid", { cause: error });
|
|
686
885
|
}
|
|
687
886
|
this.setSharedSecret(alphaBytes, betaBytes, sharedSecret);
|
|
688
887
|
}
|
|
@@ -705,7 +904,7 @@ var Crypt = class {
|
|
|
705
904
|
}
|
|
706
905
|
getKeyNonce(fromServer, packetID, generationID, packetType, dummy) {
|
|
707
906
|
if (dummy) return [Uint8Array.from(DUMMY_KEY), Uint8Array.from(DUMMY_NONCE)];
|
|
708
|
-
if (!this.#cryptoInitComplete) throw new
|
|
907
|
+
if (!this.#cryptoInitComplete) throw new HandshakeError("no session key before crypto init completes");
|
|
709
908
|
const cacheKey = makeCacheKey(fromServer, packetType, generationID);
|
|
710
909
|
let kn = this.#cachedKeys.get(cacheKey);
|
|
711
910
|
if (kn === void 0) {
|
|
@@ -971,7 +1170,7 @@ var Resolver = class {
|
|
|
971
1170
|
* address as given is the answer.
|
|
972
1171
|
*/
|
|
973
1172
|
async resolve(inputAddr, options = {}) {
|
|
974
|
-
if (!inputAddr) throw new
|
|
1173
|
+
if (!inputAddr) throw new InvalidAddressError(inputAddr, "empty address");
|
|
975
1174
|
const budget = AbortSignal.timeout(RESOLVE_BUDGET_MS);
|
|
976
1175
|
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget;
|
|
977
1176
|
try {
|
|
@@ -1039,7 +1238,7 @@ var Resolver = class {
|
|
|
1039
1238
|
try {
|
|
1040
1239
|
const srvs = await abortable(resolveSrv(`_ts3._udp.${host}`), signal);
|
|
1041
1240
|
if (!srvs || srvs.length === 0) return null;
|
|
1042
|
-
return srvs.map((srv) => ({
|
|
1241
|
+
return orderSrvRecords(srvs).map((srv) => ({
|
|
1043
1242
|
addr: joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)),
|
|
1044
1243
|
source: "SRV"
|
|
1045
1244
|
}));
|
|
@@ -1051,7 +1250,7 @@ var Resolver = class {
|
|
|
1051
1250
|
for (const domain of domains) try {
|
|
1052
1251
|
const srvs = await abortable(resolveSrv(`_tsdns._tcp.${domain}`), signal);
|
|
1053
1252
|
if (!srvs || srvs.length === 0) continue;
|
|
1054
|
-
for (const srv of srvs) {
|
|
1253
|
+
for (const srv of orderSrvRecords(srvs)) {
|
|
1055
1254
|
const result = await queryTSDNS(joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)), queryHost, signal);
|
|
1056
1255
|
if (result) return result;
|
|
1057
1256
|
}
|
|
@@ -1064,6 +1263,48 @@ var Resolver = class {
|
|
|
1064
1263
|
return (await Promise.all(domains.map((domain) => queryTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, signal)))).find((answer) => answer !== null) ?? null;
|
|
1065
1264
|
}
|
|
1066
1265
|
};
|
|
1266
|
+
/**
|
|
1267
|
+
* Put SRV records in the order RFC 2782 has a client try them: by priority,
|
|
1268
|
+
* lowest first, and within a priority by weighted random selection, so a
|
|
1269
|
+
* record of weight 2 is first twice as often as one of weight 1. Records of
|
|
1270
|
+
* weight 0 follow the others of their priority, in the order given. Node's
|
|
1271
|
+
* `resolveSrv` returns the records unsorted.
|
|
1272
|
+
*
|
|
1273
|
+
* @param random Returns a number in [0, 1); injected to make the order testable.
|
|
1274
|
+
* @internal
|
|
1275
|
+
*/
|
|
1276
|
+
function orderSrvRecords(records, random = Math.random) {
|
|
1277
|
+
const byPriority = /* @__PURE__ */ new Map();
|
|
1278
|
+
for (const record of records) {
|
|
1279
|
+
const group = byPriority.get(record.priority);
|
|
1280
|
+
if (group) group.push(record);
|
|
1281
|
+
else byPriority.set(record.priority, [record]);
|
|
1282
|
+
}
|
|
1283
|
+
const ordered = [];
|
|
1284
|
+
for (const priority of [...byPriority.keys()].sort((a, b) => a - b)) {
|
|
1285
|
+
const group = byPriority.get(priority);
|
|
1286
|
+
const weighted = group.filter((r) => r.weight > 0);
|
|
1287
|
+
const unweighted = group.filter((r) => r.weight <= 0);
|
|
1288
|
+
let sum = weighted.reduce((total, r) => total + r.weight, 0);
|
|
1289
|
+
while (weighted.length > 0) {
|
|
1290
|
+
const target = random() * sum;
|
|
1291
|
+
let running = 0;
|
|
1292
|
+
let pick = weighted.length - 1;
|
|
1293
|
+
for (let i = 0; i < weighted.length; i++) {
|
|
1294
|
+
running += weighted[i].weight;
|
|
1295
|
+
if (running > target) {
|
|
1296
|
+
pick = i;
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const [chosen] = weighted.splice(pick, 1);
|
|
1301
|
+
ordered.push(chosen);
|
|
1302
|
+
sum -= chosen.weight;
|
|
1303
|
+
}
|
|
1304
|
+
ordered.push(...unweighted);
|
|
1305
|
+
}
|
|
1306
|
+
return ordered;
|
|
1307
|
+
}
|
|
1067
1308
|
/** @internal Ask the nickname service for `nickname`'s address. */
|
|
1068
1309
|
async function resolveNickname(nickname, signal) {
|
|
1069
1310
|
try {
|
|
@@ -1406,92 +1647,6 @@ function parseQlzHeader(data) {
|
|
|
1406
1647
|
};
|
|
1407
1648
|
}
|
|
1408
1649
|
//#endregion
|
|
1409
|
-
//#region src/handshake/crypt-handshake.ts
|
|
1410
|
-
var INIT_VERSION = 1566914096;
|
|
1411
|
-
var INIT_STEP_LEN = 21;
|
|
1412
|
-
/** The RSA challenge each connection attempt, by its Crypt, has answered. */
|
|
1413
|
-
var answeredChallenges = /* @__PURE__ */ new WeakMap();
|
|
1414
|
-
/**
|
|
1415
|
-
* Handle the TS3INIT1 handshake steps.
|
|
1416
|
-
* Returns the response bytes to send, or null if nothing should be sent.
|
|
1417
|
-
* Throws a CryptoInitError for a step that cannot be answered.
|
|
1418
|
-
*/
|
|
1419
|
-
function processInit1(crypt, data) {
|
|
1420
|
-
if (data === null || data.length >= 1 && data[0] === 127) return buildInit1StartPacket();
|
|
1421
|
-
if (data.length === 0) throw new CryptoInitError("empty Init1 packet");
|
|
1422
|
-
switch (data[0]) {
|
|
1423
|
-
case 0: return buildInit1Step1Packet(data);
|
|
1424
|
-
case 1: return buildInit1Step2Packet(data);
|
|
1425
|
-
case 2: return buildInit1Step3Packet(data);
|
|
1426
|
-
case 3: return buildInit1Step4Packet(crypt, data);
|
|
1427
|
-
default: return null;
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
function buildInit1StartPacket() {
|
|
1431
|
-
const sendData = /* @__PURE__ */ new Uint8Array(21);
|
|
1432
|
-
const view = new DataView(sendData.buffer);
|
|
1433
|
-
view.setUint32(0, INIT_VERSION, false);
|
|
1434
|
-
sendData[4] = 0;
|
|
1435
|
-
const nowSec = Math.floor(Date.now() / 1e3);
|
|
1436
|
-
const clampedNow = Math.max(0, Math.min(nowSec, 4294967295));
|
|
1437
|
-
view.setUint32(5, clampedNow, false);
|
|
1438
|
-
const rng = randomBytes(4);
|
|
1439
|
-
sendData.set(rng, 9);
|
|
1440
|
-
return sendData;
|
|
1441
|
-
}
|
|
1442
|
-
function buildInit1Step1Packet(data) {
|
|
1443
|
-
if (data.length !== INIT_STEP_LEN) return null;
|
|
1444
|
-
const sendData = /* @__PURE__ */ new Uint8Array(21);
|
|
1445
|
-
sendData[0] = 1;
|
|
1446
|
-
const tsRand = data[9] | data[10] << 8 | data[11] << 16 | data[12] << 24;
|
|
1447
|
-
new DataView(sendData.buffer).setUint32(17, tsRand >>> 0, false);
|
|
1448
|
-
return sendData;
|
|
1449
|
-
}
|
|
1450
|
-
function buildInit1Step2Packet(data) {
|
|
1451
|
-
if (data.length !== INIT_STEP_LEN) return null;
|
|
1452
|
-
const sendData = /* @__PURE__ */ new Uint8Array(25);
|
|
1453
|
-
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
1454
|
-
sendData[4] = 2;
|
|
1455
|
-
sendData.set(data.slice(1, 21), 5);
|
|
1456
|
-
return sendData;
|
|
1457
|
-
}
|
|
1458
|
-
function buildInit1Step3Packet(data) {
|
|
1459
|
-
if (data.length !== 25) return null;
|
|
1460
|
-
const sendData = /* @__PURE__ */ new Uint8Array(233);
|
|
1461
|
-
sendData[0] = 3;
|
|
1462
|
-
sendData[64] = 1;
|
|
1463
|
-
sendData[128] = 1;
|
|
1464
|
-
new DataView(sendData.buffer).setUint32(129, 1, false);
|
|
1465
|
-
return sendData;
|
|
1466
|
-
}
|
|
1467
|
-
function buildInit1Step4Packet(crypt, data) {
|
|
1468
|
-
if (data.length !== 233) return null;
|
|
1469
|
-
const answered = answeredChallenges.get(crypt);
|
|
1470
|
-
if (answered !== void 0) return Buffer.from(answered.challenge).equals(Buffer.from(data)) ? answered.answer : null;
|
|
1471
|
-
const level = new DataView(data.buffer, data.byteOffset).getUint32(129, false);
|
|
1472
|
-
const y = crypt.solveRsaChallenge(data, 1, level);
|
|
1473
|
-
const alphaB64 = Buffer.from(crypt.newAlpha()).toString("base64");
|
|
1474
|
-
const omegaB64 = crypt.identity.publicKey();
|
|
1475
|
-
const cmd = buildCommandOrdered("clientinitiv", [
|
|
1476
|
-
["alpha", alphaB64],
|
|
1477
|
-
["omega", omegaB64],
|
|
1478
|
-
["ot", "1"],
|
|
1479
|
-
["ip", ""]
|
|
1480
|
-
]);
|
|
1481
|
-
const cmdBytes = Buffer.from(cmd);
|
|
1482
|
-
const sendData = new Uint8Array(301 + cmdBytes.length);
|
|
1483
|
-
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
1484
|
-
sendData[4] = 4;
|
|
1485
|
-
sendData.set(data.slice(1, 233), 5);
|
|
1486
|
-
sendData.set(y.slice(0, 64), 237);
|
|
1487
|
-
sendData.set(cmdBytes, 301);
|
|
1488
|
-
answeredChallenges.set(crypt, {
|
|
1489
|
-
challenge: Uint8Array.from(data),
|
|
1490
|
-
answer: sendData
|
|
1491
|
-
});
|
|
1492
|
-
return sendData;
|
|
1493
|
-
}
|
|
1494
|
-
//#endregion
|
|
1495
1650
|
//#region src/transport/handler.ts
|
|
1496
1651
|
var RECEIVE_PACKET_WINDOW_SIZE = 1024;
|
|
1497
1652
|
var PING_INTERVAL_MS = 5e3;
|
|
@@ -1535,6 +1690,8 @@ var PacketHandler = class {
|
|
|
1535
1690
|
* ends the connection. Without an owner the error is logged.
|
|
1536
1691
|
*/
|
|
1537
1692
|
onReceiveError = null;
|
|
1693
|
+
/** Called with the socket once it is open, before the handshake's first packet is sent. */
|
|
1694
|
+
onOpen = null;
|
|
1538
1695
|
#crypt;
|
|
1539
1696
|
#logger;
|
|
1540
1697
|
#socketFactory;
|
|
@@ -1543,7 +1700,7 @@ var PacketHandler = class {
|
|
|
1543
1700
|
#closed = false;
|
|
1544
1701
|
#stopPing = null;
|
|
1545
1702
|
#stopResend = null;
|
|
1546
|
-
#lastMessageReceived =
|
|
1703
|
+
#lastMessageReceived = performance.now();
|
|
1547
1704
|
#packetCounter = /* @__PURE__ */ new Uint16Array(9);
|
|
1548
1705
|
#generationCounter = /* @__PURE__ */ new Uint32Array(9);
|
|
1549
1706
|
#recvWindowCommand = new GenerationWindow(65536, RECEIVE_PACKET_WINDOW_SIZE);
|
|
@@ -1623,6 +1780,7 @@ var PacketHandler = class {
|
|
|
1623
1780
|
done = true;
|
|
1624
1781
|
socket.off("error", onError);
|
|
1625
1782
|
signal?.removeEventListener("abort", onAbort);
|
|
1783
|
+
this.onOpen?.(socket);
|
|
1626
1784
|
this.start(socket);
|
|
1627
1785
|
resolve();
|
|
1628
1786
|
});
|
|
@@ -1639,7 +1797,7 @@ var PacketHandler = class {
|
|
|
1639
1797
|
return;
|
|
1640
1798
|
}
|
|
1641
1799
|
this.#conn = conn;
|
|
1642
|
-
this.#lastMessageReceived =
|
|
1800
|
+
this.#lastMessageReceived = performance.now();
|
|
1643
1801
|
conn.on("message", (msg) => {
|
|
1644
1802
|
if (this.#closed) return;
|
|
1645
1803
|
try {
|
|
@@ -1797,8 +1955,8 @@ var PacketHandler = class {
|
|
|
1797
1955
|
if (dummy && pType === PacketType.Command) this.#dummyCommandIDs.add(pID);
|
|
1798
1956
|
const rp = {
|
|
1799
1957
|
packet: p,
|
|
1800
|
-
firstSend:
|
|
1801
|
-
lastSend:
|
|
1958
|
+
firstSend: performance.now(),
|
|
1959
|
+
lastSend: performance.now(),
|
|
1802
1960
|
retryCount: 0,
|
|
1803
1961
|
nextInterval: RESEND_BASE_INTERVAL_MS
|
|
1804
1962
|
};
|
|
@@ -1811,7 +1969,7 @@ var PacketHandler = class {
|
|
|
1811
1969
|
if (err) this.#logger.warn("udp send error", err);
|
|
1812
1970
|
});
|
|
1813
1971
|
} catch (error) {
|
|
1814
|
-
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1972
|
+
const normalized = error instanceof Error ? error : new Error(String(error), { cause: error });
|
|
1815
1973
|
this.#logger.error("udp send failed", normalized);
|
|
1816
1974
|
this.#triggerClose(normalized);
|
|
1817
1975
|
}
|
|
@@ -1832,12 +1990,12 @@ var PacketHandler = class {
|
|
|
1832
1990
|
clientID: 0,
|
|
1833
1991
|
generationID: this.#resolveGeneration(parsed.id, parsed.typeFlagged & 15),
|
|
1834
1992
|
data: /* @__PURE__ */ new Uint8Array(0),
|
|
1835
|
-
receivedAt:
|
|
1993
|
+
receivedAt: performance.now()
|
|
1836
1994
|
};
|
|
1837
1995
|
const decrypted = this.#decryptPacketData(p, header, ciphertext, tag);
|
|
1838
1996
|
if (decrypted === null) return;
|
|
1839
1997
|
p.data = decrypted.plaintext;
|
|
1840
|
-
this.#lastMessageReceived =
|
|
1998
|
+
this.#lastMessageReceived = performance.now();
|
|
1841
1999
|
if (packetType(p) === PacketType.Ping) {
|
|
1842
2000
|
this.#sendPong(p.id, decrypted.dummyUsed);
|
|
1843
2001
|
return;
|
|
@@ -2053,7 +2211,7 @@ var PacketHandler = class {
|
|
|
2053
2211
|
}
|
|
2054
2212
|
}
|
|
2055
2213
|
#checkResends() {
|
|
2056
|
-
const now =
|
|
2214
|
+
const now = performance.now();
|
|
2057
2215
|
if (now - this.#lastMessageReceived > PACKET_TIMEOUT_MS) {
|
|
2058
2216
|
this.#logger.warn("idle timeout");
|
|
2059
2217
|
this.#triggerClose(new ConnectionTimeoutError());
|
|
@@ -2103,7 +2261,7 @@ function applyProtocolFlags(pType, flags) {
|
|
|
2103
2261
|
return flags;
|
|
2104
2262
|
}
|
|
2105
2263
|
function hasOldNewerPacket(queue, nextID) {
|
|
2106
|
-
const now =
|
|
2264
|
+
const now = performance.now();
|
|
2107
2265
|
for (const [id, pkg] of queue) if ((id - nextID + 65536 & 65535) < 32768 && now - pkg.receivedAt > 5e3) return true;
|
|
2108
2266
|
return false;
|
|
2109
2267
|
}
|
|
@@ -2285,12 +2443,75 @@ function appendReturnCode(cmd, rc) {
|
|
|
2285
2443
|
*/
|
|
2286
2444
|
/** How long dialing may take before it fails, unless a signal says otherwise. */
|
|
2287
2445
|
var DIAL_TIMEOUT_MS = 1e4;
|
|
2446
|
+
/** How long a transfer may go without moving a byte, unless `idleTimeoutMs` says otherwise. */
|
|
2447
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
|
|
2448
|
+
/** How many bytes move between two calls of the `progress` hook, at least. */
|
|
2449
|
+
var PROGRESS_STEP_BYTES = 65536;
|
|
2450
|
+
function newIdleGuard(options) {
|
|
2451
|
+
const limitMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
2452
|
+
if (!Number.isFinite(limitMs) || limitMs < 0) throw new RangeError("idleTimeoutMs must be a finite number of milliseconds, 0 or more");
|
|
2453
|
+
const controller = new AbortController();
|
|
2454
|
+
if (limitMs === 0) return {
|
|
2455
|
+
signal: controller.signal,
|
|
2456
|
+
limitMs,
|
|
2457
|
+
touch() {},
|
|
2458
|
+
stop() {}
|
|
2459
|
+
};
|
|
2460
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`no data moved for ${limitMs} ms`, "TimeoutError")), limitMs);
|
|
2461
|
+
return {
|
|
2462
|
+
signal: controller.signal,
|
|
2463
|
+
limitMs,
|
|
2464
|
+
touch: () => timer.refresh(),
|
|
2465
|
+
stop: () => clearTimeout(timer)
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
/** The error a transfer fails with when `idle` expired, else `error` as it is. */
|
|
2469
|
+
function idleOr(idle, error) {
|
|
2470
|
+
if (!idle.signal.aborted) return error;
|
|
2471
|
+
return new FileTransferError(`file transfer stalled: no data moved for ${idle.limitMs} ms`, { cause: idle.signal.reason });
|
|
2472
|
+
}
|
|
2473
|
+
function newTransferTrace(options) {
|
|
2474
|
+
return {
|
|
2475
|
+
trace: options.trace === void 0 ? void 0 : { ...options.trace },
|
|
2476
|
+
startedAt: performance.now(),
|
|
2477
|
+
bytes: 0
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
function reportFailed(t, error, stage) {
|
|
2481
|
+
callHook(t.trace?.failed, t.trace, () => ({
|
|
2482
|
+
error,
|
|
2483
|
+
bytes: t.bytes,
|
|
2484
|
+
durationMs: performance.now() - t.startedAt,
|
|
2485
|
+
stage
|
|
2486
|
+
}));
|
|
2487
|
+
}
|
|
2288
2488
|
/**
|
|
2289
2489
|
* Open a connection to the file transfer port and send the transfer key.
|
|
2290
2490
|
* The caller owns the returned stream and must end or destroy it.
|
|
2291
2491
|
*/
|
|
2292
2492
|
async function dialFileTransfer(host, port, key, options = {}) {
|
|
2493
|
+
const t = newTransferTrace(options);
|
|
2494
|
+
const idle = newIdleGuard(options);
|
|
2495
|
+
try {
|
|
2496
|
+
return await dial(host, port, key, options, t, idle);
|
|
2497
|
+
} catch (error) {
|
|
2498
|
+
const failure = idleOr(idle, error);
|
|
2499
|
+
reportFailed(t, failure, "dial");
|
|
2500
|
+
throw failure;
|
|
2501
|
+
} finally {
|
|
2502
|
+
idle.stop();
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
async function dial(host, port, key, options, t, idle) {
|
|
2293
2506
|
const { signal, socketFactory } = options;
|
|
2507
|
+
const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
|
|
2508
|
+
const dialSignal = AbortSignal.any(signal ? [
|
|
2509
|
+
signal,
|
|
2510
|
+
timeout,
|
|
2511
|
+
idle.signal
|
|
2512
|
+
] : [timeout, idle.signal]);
|
|
2513
|
+
const addr = joinHostPort(host, String(port));
|
|
2514
|
+
callHook(t.trace?.dialStart, t.trace, () => ({ addr }));
|
|
2294
2515
|
signal?.throwIfAborted();
|
|
2295
2516
|
const socket = socketFactory ? socketFactory(host, port) : connect({
|
|
2296
2517
|
host,
|
|
@@ -2299,57 +2520,138 @@ async function dialFileTransfer(host, port, key, options = {}) {
|
|
|
2299
2520
|
const swallow = () => {};
|
|
2300
2521
|
socket.on("error", swallow);
|
|
2301
2522
|
try {
|
|
2302
|
-
await connected(socket,
|
|
2303
|
-
|
|
2523
|
+
await abortable(connected(socket), dialSignal);
|
|
2524
|
+
idle.touch();
|
|
2525
|
+
await abortable(new Promise((resolve, reject) => {
|
|
2304
2526
|
socket.write(key, (err) => err ? reject(new FileTransferError(`failed to send transfer key: ${err.message}`, { cause: err })) : resolve());
|
|
2305
|
-
});
|
|
2527
|
+
}), dialSignal);
|
|
2528
|
+
idle.touch();
|
|
2306
2529
|
} catch (error) {
|
|
2307
2530
|
socket.destroy();
|
|
2531
|
+
if (timeout.aborted && error === timeout.reason) throw new FileTransferError("file transfer connection timed out", { cause: error });
|
|
2308
2532
|
throw error;
|
|
2309
2533
|
} finally {
|
|
2310
2534
|
socket.off("error", swallow);
|
|
2311
2535
|
}
|
|
2536
|
+
callHook(t.trace?.dialDone, t.trace, () => {
|
|
2537
|
+
let localAddr = "";
|
|
2538
|
+
const local = socket;
|
|
2539
|
+
if (typeof local.localAddress === "string" && typeof local.localPort === "number") localAddr = joinHostPort(local.localAddress, String(local.localPort));
|
|
2540
|
+
return {
|
|
2541
|
+
addr,
|
|
2542
|
+
localAddr,
|
|
2543
|
+
durationMs: performance.now() - t.startedAt
|
|
2544
|
+
};
|
|
2545
|
+
});
|
|
2312
2546
|
return socket;
|
|
2313
2547
|
}
|
|
2314
2548
|
/**
|
|
2549
|
+
* A pass-through that counts what moves and reports it to `progress`. It never
|
|
2550
|
+
* fails the pipeline: a hook's error is reported, not thrown.
|
|
2551
|
+
*/
|
|
2552
|
+
function countingStream(t, total, idle) {
|
|
2553
|
+
let reported = 0;
|
|
2554
|
+
return new Transform({ transform(chunk, _encoding, callback) {
|
|
2555
|
+
t.bytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
2556
|
+
idle.touch();
|
|
2557
|
+
if (t.bytes - reported >= PROGRESS_STEP_BYTES) {
|
|
2558
|
+
reported = t.bytes;
|
|
2559
|
+
const bytes = t.bytes;
|
|
2560
|
+
callHook(t.trace?.progress, t.trace, () => ({
|
|
2561
|
+
bytes,
|
|
2562
|
+
total
|
|
2563
|
+
}));
|
|
2564
|
+
}
|
|
2565
|
+
callback(null, chunk);
|
|
2566
|
+
} });
|
|
2567
|
+
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Passes exactly `expected` bytes. One more byte fails the transfer, and is
|
|
2570
|
+
* not passed on; a stream that ends before `expected` fails it too.
|
|
2571
|
+
*/
|
|
2572
|
+
function exactSize(expected) {
|
|
2573
|
+
let received = 0;
|
|
2574
|
+
return new Transform({
|
|
2575
|
+
transform(chunk, _encoding, callback) {
|
|
2576
|
+
const room = expected - received;
|
|
2577
|
+
if (chunk.length > room) {
|
|
2578
|
+
this.push(chunk.subarray(0, room));
|
|
2579
|
+
received = expected;
|
|
2580
|
+
callback(new FileTransferError(`file transfer sent more than the announced ${expected} bytes`));
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
received += chunk.length;
|
|
2584
|
+
callback(null, chunk);
|
|
2585
|
+
},
|
|
2586
|
+
flush(callback) {
|
|
2587
|
+
callback(received === expected ? null : new FileTransferError(`file transfer ended after ${received} of the announced ${expected} bytes`));
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
/** Run a transfer's data phase, and report how the whole call ended. */
|
|
2592
|
+
async function transfer(host, port, key, total, options, move) {
|
|
2593
|
+
const t = newTransferTrace(options);
|
|
2594
|
+
const idle = newIdleGuard(options);
|
|
2595
|
+
try {
|
|
2596
|
+
let socket;
|
|
2597
|
+
try {
|
|
2598
|
+
socket = await dial(host, port, key, options, t, idle);
|
|
2599
|
+
} catch (error) {
|
|
2600
|
+
const failure = idleOr(idle, error);
|
|
2601
|
+
reportFailed(t, failure, "dial");
|
|
2602
|
+
throw failure;
|
|
2603
|
+
}
|
|
2604
|
+
try {
|
|
2605
|
+
const signal = options.signal ? AbortSignal.any([options.signal, idle.signal]) : idle.signal;
|
|
2606
|
+
await move(socket, countingStream(t, total, idle), { signal });
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
const failure = idleOr(idle, error);
|
|
2609
|
+
reportFailed(t, failure, "transfer");
|
|
2610
|
+
throw failure;
|
|
2611
|
+
}
|
|
2612
|
+
} finally {
|
|
2613
|
+
idle.stop();
|
|
2614
|
+
}
|
|
2615
|
+
const bytes = t.bytes;
|
|
2616
|
+
callHook(t.trace?.progress, t.trace, () => ({
|
|
2617
|
+
bytes,
|
|
2618
|
+
total
|
|
2619
|
+
}));
|
|
2620
|
+
callHook(t.trace?.done, t.trace, () => ({
|
|
2621
|
+
bytes,
|
|
2622
|
+
durationMs: performance.now() - t.startedAt
|
|
2623
|
+
}));
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2315
2626
|
* Wait for a stream to be connected. A `node:net` socket says so with
|
|
2316
2627
|
* `connect`; a stream from elsewhere that is already writable is taken as
|
|
2317
2628
|
* connected.
|
|
2318
2629
|
*/
|
|
2319
|
-
function connected(socket
|
|
2630
|
+
function connected(socket) {
|
|
2320
2631
|
const pending = socket.pending;
|
|
2321
2632
|
if (pending === false || pending === void 0 && socket.writable) return Promise.resolve();
|
|
2322
2633
|
return new Promise((resolve, reject) => {
|
|
2323
|
-
const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
|
|
2324
2634
|
const done = (error) => {
|
|
2325
2635
|
socket.off("connect", onConnect);
|
|
2326
2636
|
socket.off("ready", onConnect);
|
|
2327
2637
|
socket.off("error", onError);
|
|
2328
|
-
signal?.removeEventListener("abort", onAbort);
|
|
2329
|
-
timeout.removeEventListener("abort", onTimeout);
|
|
2330
2638
|
if (error === void 0) resolve();
|
|
2331
2639
|
else reject(error);
|
|
2332
2640
|
};
|
|
2333
2641
|
const onConnect = () => done();
|
|
2334
2642
|
const onError = (error) => done(new FileTransferError(`file transfer connection failed: ${error.message}`, { cause: error }));
|
|
2335
|
-
const onAbort = () => done(signal.reason);
|
|
2336
|
-
const onTimeout = () => done(new FileTransferError("file transfer connection timed out"));
|
|
2337
2643
|
socket.once("connect", onConnect);
|
|
2338
2644
|
socket.once("ready", onConnect);
|
|
2339
2645
|
socket.once("error", onError);
|
|
2340
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2341
|
-
timeout.addEventListener("abort", onTimeout, { once: true });
|
|
2342
2646
|
});
|
|
2343
2647
|
}
|
|
2344
2648
|
/** Upload `data` over a negotiated transfer. Resolves once every byte is sent. */
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
await pipeline(data, socket, options.signal ? { signal: options.signal } : {});
|
|
2649
|
+
function uploadFileData(host, info, data, options = {}) {
|
|
2650
|
+
return transfer(host, info.port, info.fileTransferKey, 0, options, (socket, counter, pipeOptions) => pipeline(data, counter, socket, pipeOptions));
|
|
2348
2651
|
}
|
|
2349
2652
|
/** Download a negotiated transfer into `dest`. Resolves once the file is written. */
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
await pipeline(socket, dest, options.signal ? { signal: options.signal } : {});
|
|
2653
|
+
function downloadFileData(host, info, dest, options = {}) {
|
|
2654
|
+
return transfer(host, info.port, info.fileTransferKey, Number(info.size), options, (socket, counter, pipeOptions) => pipeline(socket, exactSize(Number(info.size)), counter, dest, pipeOptions));
|
|
2353
2655
|
}
|
|
2354
2656
|
//#endregion
|
|
2355
2657
|
//#region src/events.ts
|
|
@@ -2370,6 +2672,19 @@ function buildEventChain(middlewares, base) {
|
|
|
2370
2672
|
//#endregion
|
|
2371
2673
|
//#region src/capabilities.ts
|
|
2372
2674
|
/**
|
|
2675
|
+
* What the connected server is, and what that means for the caller.
|
|
2676
|
+
*
|
|
2677
|
+
* TS3 and TS6 differ in ways that stay invisible until something breaks. The
|
|
2678
|
+
* clearest: the server hashes a client's public key with SHA-1 on TS3 and
|
|
2679
|
+
* SHA-256 on TS6, so a client that assumes either one computes a
|
|
2680
|
+
* `client_unique_identifier` the other server never sends.
|
|
2681
|
+
*
|
|
2682
|
+
* Everything below comes from the handshake — `initserver` carries
|
|
2683
|
+
* `virtualserver_version`, the one place the server names itself, along with
|
|
2684
|
+
* its name, icon, banner and welcome message — so reading it costs no extra
|
|
2685
|
+
* round trip.
|
|
2686
|
+
*/
|
|
2687
|
+
/**
|
|
2373
2688
|
* Read what a server says about itself in `initserver`, or a version string
|
|
2374
2689
|
* alone, such as "6.0.0-beta13.1 [Build: 1]".
|
|
2375
2690
|
*/
|
|
@@ -2389,7 +2704,11 @@ function parseServerCapabilities(initserver) {
|
|
|
2389
2704
|
name: params["virtualserver_name"] ?? "",
|
|
2390
2705
|
iconID: parseIconID(params["virtualserver_icon_id"]),
|
|
2391
2706
|
hostBannerImageURL: params["virtualserver_hostbanner_gfx_url"]?.trim() || void 0,
|
|
2392
|
-
welcomeMessage: params["virtualserver_welcomemessage"] ?? ""
|
|
2707
|
+
welcomeMessage: params["virtualserver_welcomemessage"] ?? "",
|
|
2708
|
+
serverID: asServerID(parseUint64(params["virtualserver_id"] ?? "")),
|
|
2709
|
+
serverUID: params["virtualserver_unique_identifier"] ?? "",
|
|
2710
|
+
hostMessage: params["virtualserver_hostmessage"] ?? "",
|
|
2711
|
+
hostMessageMode: hostMessageMode(params["virtualserver_hostmessage_mode"])
|
|
2393
2712
|
};
|
|
2394
2713
|
}
|
|
2395
2714
|
/**
|
|
@@ -2401,6 +2720,10 @@ function parseServerCapabilities(initserver) {
|
|
|
2401
2720
|
function unknownServerCapabilities() {
|
|
2402
2721
|
return parseServerCapabilities("");
|
|
2403
2722
|
}
|
|
2723
|
+
function hostMessageMode(value) {
|
|
2724
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
2725
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
2726
|
+
}
|
|
2404
2727
|
//#endregion
|
|
2405
2728
|
//#region src/handshake/license.ts
|
|
2406
2729
|
var LICENSE_ROOT_KEY = new Uint8Array([
|
|
@@ -2589,7 +2912,7 @@ function deriveKeyFromBlock(block, parent) {
|
|
|
2589
2912
|
* Mirrors Go's handshake.CryptoInit2.
|
|
2590
2913
|
*/
|
|
2591
2914
|
function cryptoInit2(crypt, license, omega, proof, beta, privateKey) {
|
|
2592
|
-
if (crypt.alpha.length === 0) throw new
|
|
2915
|
+
if (crypt.alpha.length === 0) throw new HandshakeError("initivexpand2 arrived before clientinitiv was sent");
|
|
2593
2916
|
const licenseBytes = Buffer.from(license, "base64");
|
|
2594
2917
|
const omegaBytes = Buffer.from(omega, "base64");
|
|
2595
2918
|
const proofBytes = Buffer.from(proof, "base64");
|
|
@@ -2598,14 +2921,14 @@ function cryptoInit2(crypt, license, omega, proof, beta, privateKey) {
|
|
|
2598
2921
|
try {
|
|
2599
2922
|
serverPubKey = importPublicKey(omegaBytes);
|
|
2600
2923
|
} catch (error) {
|
|
2601
|
-
throw new
|
|
2924
|
+
throw new HandshakeError("the server's public key is invalid", { cause: error });
|
|
2602
2925
|
}
|
|
2603
|
-
if (!verifySign(serverPubKey, licenseBytes, proofBytes)) throw new
|
|
2926
|
+
if (!verifySign(serverPubKey, licenseBytes, proofBytes)) throw new HandshakeError("the server's license proof is not valid");
|
|
2604
2927
|
let sharedSecret;
|
|
2605
2928
|
try {
|
|
2606
2929
|
sharedSecret = getSharedSecret2(parseLicenses(licenseBytes).deriveKey(), privateKey);
|
|
2607
2930
|
} catch (error) {
|
|
2608
|
-
throw new
|
|
2931
|
+
throw new HandshakeError("the server's license is invalid", { cause: error });
|
|
2609
2932
|
}
|
|
2610
2933
|
crypt.setSharedSecret(crypt.alpha, betaBytes, sharedSecret);
|
|
2611
2934
|
}
|
|
@@ -2618,6 +2941,7 @@ function handleHandshakeInitIV(client, params) {
|
|
|
2618
2941
|
const omega = params["omega"] ?? "";
|
|
2619
2942
|
client.handler.receivedFinalInitAck();
|
|
2620
2943
|
client.crypt.initCrypto(alpha, beta, omega);
|
|
2944
|
+
client.markHandshakeDone(omega);
|
|
2621
2945
|
client.logger.info("crypto initialized (P-256 path), sending clientinit");
|
|
2622
2946
|
sendClientInit(client);
|
|
2623
2947
|
}
|
|
@@ -2631,6 +2955,7 @@ function handleHandshakeExpand2(client, params) {
|
|
|
2631
2955
|
const beta = params["beta"] ?? "";
|
|
2632
2956
|
const privateKey = sendClientEkPacket(client, beta);
|
|
2633
2957
|
cryptoInit2(client.crypt, license, omega, proof, beta, privateKey);
|
|
2958
|
+
client.markHandshakeDone(omega);
|
|
2634
2959
|
sendClientInit(client);
|
|
2635
2960
|
}
|
|
2636
2961
|
/** Handle `initserver` — marks the client as connected. */
|
|
@@ -2643,7 +2968,7 @@ function handleInitServer(client, params) {
|
|
|
2643
2968
|
}
|
|
2644
2969
|
client.capabilities = parseServerCapabilities(params);
|
|
2645
2970
|
client.logger.info("connected to server", { selfId: client.clid });
|
|
2646
|
-
client.markConnected();
|
|
2971
|
+
client.markConnected(params);
|
|
2647
2972
|
setImmediate(() => {
|
|
2648
2973
|
const updateCmd = buildCommand("clientupdate", {
|
|
2649
2974
|
client_input_muted: "0",
|
|
@@ -2807,7 +3132,7 @@ var CommandThrottle = class CommandThrottle {
|
|
|
2807
3132
|
static TOKEN_RATE = 4;
|
|
2808
3133
|
static TOKEN_MAX = 8;
|
|
2809
3134
|
#tokens = 5;
|
|
2810
|
-
#lastUpdate =
|
|
3135
|
+
#lastUpdate = performance.now();
|
|
2811
3136
|
#waiters = [];
|
|
2812
3137
|
#timer;
|
|
2813
3138
|
/**
|
|
@@ -2839,7 +3164,7 @@ var CommandThrottle = class CommandThrottle {
|
|
|
2839
3164
|
});
|
|
2840
3165
|
}
|
|
2841
3166
|
#refill() {
|
|
2842
|
-
const now =
|
|
3167
|
+
const now = performance.now();
|
|
2843
3168
|
const elapsed = (now - this.#lastUpdate) / 1e3;
|
|
2844
3169
|
this.#tokens = Math.min(this.#tokens + elapsed * CommandThrottle.TOKEN_RATE, CommandThrottle.TOKEN_MAX);
|
|
2845
3170
|
this.#lastUpdate = now;
|
|
@@ -3013,6 +3338,13 @@ var DEFAULT_COMMAND_TIMEOUT_MS = 1e4;
|
|
|
3013
3338
|
var DISCONNECT_TIMEOUT_MS = 1e3;
|
|
3014
3339
|
/** How long each reconnection attempt may take. */
|
|
3015
3340
|
var RECONNECT_ATTEMPT_TIMEOUT_MS = 3e4;
|
|
3341
|
+
/** The `initserver` parameters a trace never sees, because they could carry a secret. */
|
|
3342
|
+
var SECRET_INITSERVER_PARAMS = /* @__PURE__ */ new Set([
|
|
3343
|
+
"client_server_password",
|
|
3344
|
+
"client_default_channel_password",
|
|
3345
|
+
"client_default_token",
|
|
3346
|
+
"client_key_offset"
|
|
3347
|
+
]);
|
|
3016
3348
|
function newConnectAttempt() {
|
|
3017
3349
|
let resolve;
|
|
3018
3350
|
let reject;
|
|
@@ -3026,6 +3358,8 @@ function newConnectAttempt() {
|
|
|
3026
3358
|
promise,
|
|
3027
3359
|
signal: controller.signal,
|
|
3028
3360
|
settled: false,
|
|
3361
|
+
trace: void 0,
|
|
3362
|
+
onFail: void 0,
|
|
3029
3363
|
resolve() {
|
|
3030
3364
|
if (attempt.settled) return;
|
|
3031
3365
|
attempt.settled = true;
|
|
@@ -3034,12 +3368,14 @@ function newConnectAttempt() {
|
|
|
3034
3368
|
reject(error) {
|
|
3035
3369
|
if (attempt.settled) return;
|
|
3036
3370
|
attempt.settled = true;
|
|
3371
|
+
attempt.onFail?.(error);
|
|
3037
3372
|
controller.abort(error);
|
|
3038
3373
|
reject(error);
|
|
3039
3374
|
},
|
|
3040
3375
|
rejectAfter(error, until) {
|
|
3041
3376
|
if (attempt.settled) return;
|
|
3042
3377
|
attempt.settled = true;
|
|
3378
|
+
attempt.onFail?.(error);
|
|
3043
3379
|
controller.abort(error);
|
|
3044
3380
|
until.finally(() => reject(error));
|
|
3045
3381
|
}
|
|
@@ -3086,6 +3422,10 @@ var Client = class {
|
|
|
3086
3422
|
#clientInitOptions;
|
|
3087
3423
|
#socketFactory;
|
|
3088
3424
|
#fileTransferSocketFactory;
|
|
3425
|
+
/** A copy of the caller's trace, so changing theirs later changes nothing. */
|
|
3426
|
+
#trace;
|
|
3427
|
+
/** The session in progress, for its `sessionEnd`; cleared when that has been reported. */
|
|
3428
|
+
#session;
|
|
3089
3429
|
/** Undefined when the client does not reconnect. */
|
|
3090
3430
|
#reconnectOptions;
|
|
3091
3431
|
/**
|
|
@@ -3106,6 +3446,8 @@ var Client = class {
|
|
|
3106
3446
|
* the new one instead.
|
|
3107
3447
|
*/
|
|
3108
3448
|
#leaving;
|
|
3449
|
+
/** Ends a disconnect()'s wait for its goodbye to be answered, while it waits. */
|
|
3450
|
+
#goodbyeAnswered;
|
|
3109
3451
|
/** How many times disconnect() was called, so a waiting connect() sees one. */
|
|
3110
3452
|
#disconnects = 0;
|
|
3111
3453
|
/**
|
|
@@ -3141,6 +3483,7 @@ var Client = class {
|
|
|
3141
3483
|
this.#socketFactory = options.socketFactory;
|
|
3142
3484
|
this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
|
|
3143
3485
|
this.#reconnectOptions = options.reconnect;
|
|
3486
|
+
this.#trace = options.trace === void 0 ? void 0 : { ...options.trace };
|
|
3144
3487
|
this.#crypt = new Crypt(identity);
|
|
3145
3488
|
this.#handler = this.#newHandler();
|
|
3146
3489
|
const self = this;
|
|
@@ -3175,7 +3518,8 @@ var Client = class {
|
|
|
3175
3518
|
set capabilities(value) {
|
|
3176
3519
|
self.#capabilities = value;
|
|
3177
3520
|
},
|
|
3178
|
-
markConnected: () => this.#markConnected(),
|
|
3521
|
+
markConnected: (initServer) => this.#markConnected(initServer),
|
|
3522
|
+
markHandshakeDone: (serverPublicKey) => this.#markHandshakeDone(serverPublicKey),
|
|
3179
3523
|
execNoWait: (command) => this.#execNoWait(command, void 0, true),
|
|
3180
3524
|
handlePacket: (packet) => this.#handlePacket(packet)
|
|
3181
3525
|
};
|
|
@@ -3214,6 +3558,9 @@ var Client = class {
|
|
|
3214
3558
|
* after `await client.connect()` still hears it.
|
|
3215
3559
|
*/
|
|
3216
3560
|
async connect(options = {}) {
|
|
3561
|
+
return this.#connect(options, 1, false);
|
|
3562
|
+
}
|
|
3563
|
+
async #connect(options, attemptNumber, reconnect) {
|
|
3217
3564
|
const { signal } = options;
|
|
3218
3565
|
signal?.throwIfAborted();
|
|
3219
3566
|
const disconnects = this.#disconnects;
|
|
@@ -3226,14 +3573,61 @@ var Client = class {
|
|
|
3226
3573
|
this.#resetForConnect();
|
|
3227
3574
|
this.#status = ClientStatus.Connecting;
|
|
3228
3575
|
const attempt = this.#attempt;
|
|
3576
|
+
const trace = {
|
|
3577
|
+
attempt: attemptNumber,
|
|
3578
|
+
reconnect,
|
|
3579
|
+
startedAt: performance.now(),
|
|
3580
|
+
connectStartedAt: 0,
|
|
3581
|
+
phase: ConnectPhase.Open,
|
|
3582
|
+
target: "",
|
|
3583
|
+
sessionStarted: false,
|
|
3584
|
+
welcomeDone: false,
|
|
3585
|
+
channels: 0
|
|
3586
|
+
};
|
|
3587
|
+
attempt.trace = trace;
|
|
3588
|
+
attempt.onFail = (error) => this.#callHook(this.#trace?.attemptFailed, () => ({
|
|
3589
|
+
attempt: trace.attempt,
|
|
3590
|
+
reconnect: trace.reconnect,
|
|
3591
|
+
phase: trace.phase,
|
|
3592
|
+
error,
|
|
3593
|
+
target: trace.target,
|
|
3594
|
+
durationMs: performance.now() - trace.startedAt
|
|
3595
|
+
}));
|
|
3229
3596
|
const onAbort = () => this.#failConnection(abortReason(signal));
|
|
3230
3597
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3231
3598
|
try {
|
|
3232
3599
|
try {
|
|
3233
|
-
|
|
3600
|
+
this.#callHook(this.#trace?.resolveStart, () => ({
|
|
3601
|
+
attempt: trace.attempt,
|
|
3602
|
+
reconnect: trace.reconnect,
|
|
3603
|
+
addr: this.#addr
|
|
3604
|
+
}));
|
|
3605
|
+
const resolveStarted = performance.now();
|
|
3606
|
+
const resolution = await this.#resolveAddr(attempt.signal);
|
|
3607
|
+
const targetAddr = resolution.target;
|
|
3608
|
+
trace.target = targetAddr;
|
|
3609
|
+
if (!attempt.settled) this.#callHook(this.#trace?.resolveDone, () => ({
|
|
3610
|
+
attempt: trace.attempt,
|
|
3611
|
+
reconnect: trace.reconnect,
|
|
3612
|
+
addr: this.#addr,
|
|
3613
|
+
target: targetAddr,
|
|
3614
|
+
source: resolution.source,
|
|
3615
|
+
candidates: resolution.candidates,
|
|
3616
|
+
error: resolution.error,
|
|
3617
|
+
durationMs: performance.now() - resolveStarted
|
|
3618
|
+
}));
|
|
3234
3619
|
if (!attempt.settled) {
|
|
3235
3620
|
this.#logger.info("connecting to server", { address: targetAddr });
|
|
3621
|
+
trace.connectStartedAt = performance.now();
|
|
3622
|
+
this.#callHook(this.#trace?.connectStart, () => ({
|
|
3623
|
+
attempt: trace.attempt,
|
|
3624
|
+
reconnect: trace.reconnect,
|
|
3625
|
+
target: targetAddr
|
|
3626
|
+
}));
|
|
3627
|
+
}
|
|
3628
|
+
if (!attempt.settled) {
|
|
3236
3629
|
await this.#handler.connect(targetAddr, attempt.signal);
|
|
3630
|
+
if (trace.phase === ConnectPhase.Open) trace.phase = ConnectPhase.Handshake;
|
|
3237
3631
|
}
|
|
3238
3632
|
} catch (error) {
|
|
3239
3633
|
this.#failConnection(error);
|
|
@@ -3275,14 +3669,26 @@ var Client = class {
|
|
|
3275
3669
|
}
|
|
3276
3670
|
async #leave(wasConnected, signal) {
|
|
3277
3671
|
this.#logger.info("disconnecting from server");
|
|
3278
|
-
if (wasConnected)
|
|
3279
|
-
|
|
3672
|
+
if (wasConnected) {
|
|
3673
|
+
this.#callHook(this.#trace?.disconnectStart, () => ({}));
|
|
3674
|
+
const goodbyeStarted = performance.now();
|
|
3675
|
+
const left = new Promise((resolve) => {
|
|
3676
|
+
this.#goodbyeAnswered = () => resolve(true);
|
|
3677
|
+
});
|
|
3678
|
+
const answered = this.#exec("clientdisconnect reasonmsg=Shutdown", {
|
|
3280
3679
|
signal,
|
|
3281
3680
|
timeout: DISCONNECT_TIMEOUT_MS
|
|
3282
|
-
}, true);
|
|
3283
|
-
|
|
3681
|
+
}, true).then(() => true, () => false);
|
|
3682
|
+
const acknowledged = await Promise.race([answered, left]);
|
|
3683
|
+
this.#goodbyeAnswered = void 0;
|
|
3684
|
+
this.#callHook(this.#trace?.disconnectDone, () => ({
|
|
3685
|
+
acknowledged,
|
|
3686
|
+
durationMs: performance.now() - goodbyeStarted
|
|
3687
|
+
}));
|
|
3688
|
+
}
|
|
3284
3689
|
this.#handler.close();
|
|
3285
3690
|
this.#cmdTrack.reset(new NotConnectedError());
|
|
3691
|
+
this.#endSession(void 0);
|
|
3286
3692
|
await this.#announceDisconnected(void 0);
|
|
3287
3693
|
}
|
|
3288
3694
|
/** `await using client = …` disconnects when the scope ends. */
|
|
@@ -3424,67 +3830,95 @@ var Client = class {
|
|
|
3424
3830
|
* ```
|
|
3425
3831
|
*
|
|
3426
3832
|
* Events that arrive while the loop body runs are buffered, so none is
|
|
3427
|
-
* missed
|
|
3428
|
-
*
|
|
3429
|
-
* `signal`
|
|
3430
|
-
*
|
|
3431
|
-
*
|
|
3833
|
+
* missed. The buffer is unbounded: an iterator that is not consumed as fast
|
|
3834
|
+
* as events arrive holds every one of them in memory until it is read, so
|
|
3835
|
+
* stop iterating (or abort `signal`) when you stop reading. Voice is the
|
|
3836
|
+
* exception: at most 256 `voiceData` frames are buffered, and the rest are
|
|
3837
|
+
* dropped, as for a listener that falls behind.
|
|
3838
|
+
*
|
|
3839
|
+
* Iteration ends when the loop exits early (`break`, `return`, a throw), and
|
|
3840
|
+
* — for every event but `disconnected` — when the client disconnects. When
|
|
3841
|
+
* `signal` aborts, listening stops at once, whatever is buffered is
|
|
3842
|
+
* discarded, and the pending and every later `next()` reject with the
|
|
3843
|
+
* signal's reason, as `events.on` in Node does.
|
|
3844
|
+
*
|
|
3845
|
+
* `next()` may be called again before an earlier call has settled; the calls
|
|
3846
|
+
* are answered in order.
|
|
3432
3847
|
*/
|
|
3433
3848
|
events(event, options = {}) {
|
|
3434
3849
|
const { signal } = options;
|
|
3435
3850
|
const buffered = [];
|
|
3436
|
-
|
|
3851
|
+
const waiting = [];
|
|
3437
3852
|
let ended = false;
|
|
3853
|
+
let aborted = false;
|
|
3854
|
+
const finishWaiting = () => {
|
|
3855
|
+
for (const waiter of waiting.splice(0)) waiter.resolve({
|
|
3856
|
+
value: void 0,
|
|
3857
|
+
done: true
|
|
3858
|
+
});
|
|
3859
|
+
};
|
|
3438
3860
|
const stopListening = this.on(event, ((payload) => {
|
|
3439
3861
|
if (ended) return;
|
|
3862
|
+
const waiter = waiting.shift();
|
|
3863
|
+
if (waiter) {
|
|
3864
|
+
waiter.resolve({
|
|
3865
|
+
value: payload,
|
|
3866
|
+
done: false
|
|
3867
|
+
});
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3440
3870
|
if (event === "voiceData" && buffered.length >= MAX_PENDING_DELIVERIES) {
|
|
3441
3871
|
this.#dropVoice();
|
|
3442
3872
|
return;
|
|
3443
3873
|
}
|
|
3444
3874
|
buffered.push(payload);
|
|
3445
|
-
wake?.();
|
|
3446
3875
|
}));
|
|
3447
3876
|
const stopOnDisconnect = event === "disconnected" ? void 0 : this.on("disconnected", () => {
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
wake?.();
|
|
3877
|
+
cleanup();
|
|
3878
|
+
finishWaiting();
|
|
3451
3879
|
});
|
|
3452
3880
|
const cleanup = () => {
|
|
3453
3881
|
ended = true;
|
|
3454
3882
|
stopListening();
|
|
3455
3883
|
stopOnDisconnect?.();
|
|
3456
3884
|
signal?.removeEventListener("abort", abort);
|
|
3457
|
-
wake?.();
|
|
3458
3885
|
};
|
|
3459
3886
|
const abort = () => {
|
|
3887
|
+
aborted = true;
|
|
3460
3888
|
buffered.length = 0;
|
|
3461
3889
|
cleanup();
|
|
3890
|
+
const reason = abortReason(signal);
|
|
3891
|
+
for (const waiter of waiting.splice(0)) waiter.reject(reason);
|
|
3462
3892
|
};
|
|
3463
3893
|
if (signal?.aborted) abort();
|
|
3464
3894
|
else signal?.addEventListener("abort", abort, { once: true });
|
|
3465
3895
|
const iterator = {
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
wake = void 0;
|
|
3470
|
-
}
|
|
3471
|
-
if (buffered.length > 0) return {
|
|
3896
|
+
next() {
|
|
3897
|
+
if (aborted) return Promise.reject(abortReason(signal));
|
|
3898
|
+
if (buffered.length > 0) return Promise.resolve({
|
|
3472
3899
|
value: buffered.shift(),
|
|
3473
3900
|
done: false
|
|
3474
|
-
};
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3901
|
+
});
|
|
3902
|
+
if (ended) {
|
|
3903
|
+
cleanup();
|
|
3904
|
+
return Promise.resolve({
|
|
3905
|
+
value: void 0,
|
|
3906
|
+
done: true
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
return new Promise((resolve, reject) => waiting.push({
|
|
3910
|
+
resolve,
|
|
3911
|
+
reject
|
|
3912
|
+
}));
|
|
3480
3913
|
},
|
|
3481
|
-
|
|
3914
|
+
return() {
|
|
3482
3915
|
buffered.length = 0;
|
|
3483
3916
|
cleanup();
|
|
3484
|
-
|
|
3917
|
+
finishWaiting();
|
|
3918
|
+
return Promise.resolve({
|
|
3485
3919
|
value: void 0,
|
|
3486
3920
|
done: true
|
|
3487
|
-
};
|
|
3921
|
+
});
|
|
3488
3922
|
},
|
|
3489
3923
|
[Symbol.asyncIterator]() {
|
|
3490
3924
|
return iterator;
|
|
@@ -3555,12 +3989,108 @@ var Client = class {
|
|
|
3555
3989
|
socketFactory: this.#fileTransferSocketFactory
|
|
3556
3990
|
});
|
|
3557
3991
|
}
|
|
3558
|
-
#markConnected() {
|
|
3992
|
+
#markConnected(initServer = {}) {
|
|
3559
3993
|
if (this.#status !== ClientStatus.Connecting || this.#attempt.settled) return;
|
|
3560
3994
|
this.#status = ClientStatus.Connected;
|
|
3561
3995
|
this.#attempt.resolve();
|
|
3996
|
+
const trace = this.#attempt.trace;
|
|
3997
|
+
if (trace !== void 0) {
|
|
3998
|
+
trace.sessionStarted = true;
|
|
3999
|
+
this.#session = {
|
|
4000
|
+
clientID: asClientID(this.#clid),
|
|
4001
|
+
startedAt: performance.now(),
|
|
4002
|
+
attempt: trace.attempt,
|
|
4003
|
+
reconnect: trace.reconnect
|
|
4004
|
+
};
|
|
4005
|
+
this.#callHook(this.#trace?.sessionStart, () => {
|
|
4006
|
+
const safe = {};
|
|
4007
|
+
for (const [key, value] of Object.entries(initServer)) if (!SECRET_INITSERVER_PARAMS.has(key)) safe[key] = value;
|
|
4008
|
+
const talkPower = Number.parseInt(initServer["client_talk_power"] ?? "", 10);
|
|
4009
|
+
return {
|
|
4010
|
+
attempt: trace.attempt,
|
|
4011
|
+
reconnect: trace.reconnect,
|
|
4012
|
+
clientID: asClientID(this.#clid),
|
|
4013
|
+
nickname: initServer["acn"] ?? this.#nickname,
|
|
4014
|
+
talkPower: Number.isNaN(talkPower) ? 0 : talkPower,
|
|
4015
|
+
initServer: Object.freeze(safe),
|
|
4016
|
+
capabilities: this.#capabilities,
|
|
4017
|
+
durationMs: performance.now() - trace.connectStartedAt
|
|
4018
|
+
};
|
|
4019
|
+
});
|
|
4020
|
+
}
|
|
3562
4021
|
this.#emit("connected", void 0);
|
|
3563
4022
|
}
|
|
4023
|
+
/** The socket is open: the attempt's handshake begins. */
|
|
4024
|
+
#markConnectDone(socket) {
|
|
4025
|
+
const trace = this.#attempt.trace;
|
|
4026
|
+
if (trace === void 0 || this.#attempt.settled || trace.phase !== ConnectPhase.Open) return;
|
|
4027
|
+
trace.phase = ConnectPhase.Handshake;
|
|
4028
|
+
this.#callHook(this.#trace?.connectDone, () => {
|
|
4029
|
+
let localAddr = "";
|
|
4030
|
+
try {
|
|
4031
|
+
const local = socket.address?.();
|
|
4032
|
+
if (local !== void 0) localAddr = joinHostPort(local.address, String(local.port));
|
|
4033
|
+
} catch {}
|
|
4034
|
+
return {
|
|
4035
|
+
attempt: trace.attempt,
|
|
4036
|
+
reconnect: trace.reconnect,
|
|
4037
|
+
target: trace.target,
|
|
4038
|
+
localAddr,
|
|
4039
|
+
durationMs: performance.now() - trace.connectStartedAt
|
|
4040
|
+
};
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
#markHandshakeDone(serverPublicKey = "") {
|
|
4044
|
+
const trace = this.#attempt.trace;
|
|
4045
|
+
if (trace === void 0 || this.#attempt.settled || trace.phase !== ConnectPhase.Handshake) return;
|
|
4046
|
+
trace.phase = ConnectPhase.Join;
|
|
4047
|
+
this.#callHook(this.#trace?.handshakeDone, () => {
|
|
4048
|
+
const puzzle = puzzleOf(this.#crypt);
|
|
4049
|
+
return {
|
|
4050
|
+
attempt: trace.attempt,
|
|
4051
|
+
reconnect: trace.reconnect,
|
|
4052
|
+
durationMs: performance.now() - trace.connectStartedAt,
|
|
4053
|
+
puzzleLevel: puzzle.level,
|
|
4054
|
+
puzzleDurationMs: puzzle.durationMs,
|
|
4055
|
+
resends: this.#handler.resends,
|
|
4056
|
+
serverPublicKey
|
|
4057
|
+
};
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
#markWelcomeDone() {
|
|
4061
|
+
const trace = this.#attempt.trace;
|
|
4062
|
+
if (trace === void 0 || !trace.sessionStarted || trace.welcomeDone) return;
|
|
4063
|
+
if (this.#status !== ClientStatus.Connected) return;
|
|
4064
|
+
trace.welcomeDone = true;
|
|
4065
|
+
this.#callHook(this.#trace?.welcomeDone, () => ({
|
|
4066
|
+
attempt: trace.attempt,
|
|
4067
|
+
reconnect: trace.reconnect,
|
|
4068
|
+
channelID: this.channelID,
|
|
4069
|
+
channels: trace.channels,
|
|
4070
|
+
clients: this.#clients.size,
|
|
4071
|
+
durationMs: performance.now() - trace.connectStartedAt
|
|
4072
|
+
}));
|
|
4073
|
+
}
|
|
4074
|
+
/** Report the end of the session in progress, once. */
|
|
4075
|
+
#endSession(error) {
|
|
4076
|
+
const session = this.#session;
|
|
4077
|
+
if (session === void 0) return;
|
|
4078
|
+
this.#session = void 0;
|
|
4079
|
+
this.#callHook(this.#trace?.sessionEnd, () => ({
|
|
4080
|
+
clientID: session.clientID,
|
|
4081
|
+
error,
|
|
4082
|
+
durationMs: performance.now() - session.startedAt,
|
|
4083
|
+
attempt: session.attempt,
|
|
4084
|
+
reconnect: session.reconnect
|
|
4085
|
+
}));
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Call a trace hook, if there is one, and never let it touch the
|
|
4089
|
+
* connection: see callHook. The info is built only when there is a hook.
|
|
4090
|
+
*/
|
|
4091
|
+
#callHook(hook, info) {
|
|
4092
|
+
callHook(hook, this.#trace, info);
|
|
4093
|
+
}
|
|
3564
4094
|
#newHandler() {
|
|
3565
4095
|
const handler = new PacketHandler(this.#crypt, this.#logger, this.#socketFactory);
|
|
3566
4096
|
handler.onPacket = (p) => {
|
|
@@ -3569,6 +4099,9 @@ var Client = class {
|
|
|
3569
4099
|
handler.onClosed = (err) => {
|
|
3570
4100
|
if (handler === this.#handler) this.#handleConnectionClosed(err);
|
|
3571
4101
|
};
|
|
4102
|
+
handler.onOpen = (socket) => {
|
|
4103
|
+
if (handler === this.#handler) this.#markConnectDone(socket);
|
|
4104
|
+
};
|
|
3572
4105
|
handler.onReceiveError = (error) => {
|
|
3573
4106
|
if (handler === this.#handler) this.#handleReceiveError(error);
|
|
3574
4107
|
};
|
|
@@ -3591,23 +4124,36 @@ var Client = class {
|
|
|
3591
4124
|
const timer = setTimeout(() => budget.abort(new TeamspeakError("address resolution took too long")), RESOLVE_BUDGET_MS);
|
|
3592
4125
|
const lookup = AbortSignal.any([signal, budget.signal]);
|
|
3593
4126
|
try {
|
|
3594
|
-
|
|
4127
|
+
const resolved = await abortable(this.#resolver.resolve(this.#addr, { signal: lookup }), lookup);
|
|
4128
|
+
const first = resolved[0];
|
|
4129
|
+
return {
|
|
4130
|
+
target: first?.addr ?? fallback,
|
|
4131
|
+
source: first?.source ?? "",
|
|
4132
|
+
candidates: resolved,
|
|
4133
|
+
error: void 0
|
|
4134
|
+
};
|
|
3595
4135
|
} catch (error) {
|
|
3596
4136
|
signal.throwIfAborted();
|
|
3597
|
-
|
|
3598
|
-
|
|
4137
|
+
const reason = budget.signal.aborted ? budget.signal.reason : error;
|
|
4138
|
+
this.#logger.warn("address resolution failed, falling back to direct", { error: String(reason) });
|
|
4139
|
+
return {
|
|
4140
|
+
target: fallback,
|
|
4141
|
+
source: "",
|
|
4142
|
+
candidates: [],
|
|
4143
|
+
error: reason
|
|
4144
|
+
};
|
|
3599
4145
|
} finally {
|
|
3600
4146
|
clearTimeout(timer);
|
|
3601
4147
|
}
|
|
3602
4148
|
}
|
|
3603
4149
|
/**
|
|
3604
4150
|
* A datagram from the server could not be handled. During the handshake that
|
|
3605
|
-
* is the connection failing, reported as a
|
|
4151
|
+
* is the connection failing, reported as a HandshakeError with the cause;
|
|
3606
4152
|
* once connected, one bad packet is not worth the session, so it is dropped.
|
|
3607
4153
|
*/
|
|
3608
4154
|
#handleReceiveError(error) {
|
|
3609
4155
|
if (this.#status !== ClientStatus.Connected) {
|
|
3610
|
-
this.#failConnection(error instanceof
|
|
4156
|
+
this.#failConnection(error instanceof HandshakeError ? error : new HandshakeError("the handshake failed on a malformed packet", { cause: error }));
|
|
3611
4157
|
return;
|
|
3612
4158
|
}
|
|
3613
4159
|
this.#logger.debug("dropped a malformed packet", { error: String(error) });
|
|
@@ -3674,8 +4220,14 @@ var Client = class {
|
|
|
3674
4220
|
}
|
|
3675
4221
|
if (cmd.name === "channellistfinished") {
|
|
3676
4222
|
this.#cmdTrack.signalWelcomeComplete();
|
|
4223
|
+
this.#markWelcomeDone();
|
|
3677
4224
|
return;
|
|
3678
4225
|
}
|
|
4226
|
+
if (cmd.name === "channellist") {
|
|
4227
|
+
const trace = this.#attempt.trace;
|
|
4228
|
+
if (trace !== void 0 && !trace.welcomeDone) trace.channels++;
|
|
4229
|
+
}
|
|
4230
|
+
if (cmd.name === "notifyclientleftview" && this.#goodbyeAnswered !== void 0 && cmd.params["clid"] === String(this.#clid)) this.#goodbyeAnswered();
|
|
3679
4231
|
if (cmd.name.startsWith("notify")) {
|
|
3680
4232
|
if (this.#cmdTrack.expects(cmd.name)) {
|
|
3681
4233
|
this.#cmdTrack.buffer(cmd.params, cmd.name);
|
|
@@ -3786,7 +4338,7 @@ var Client = class {
|
|
|
3786
4338
|
*/
|
|
3787
4339
|
#dropVoice() {
|
|
3788
4340
|
this.#voiceDropped++;
|
|
3789
|
-
const now =
|
|
4341
|
+
const now = performance.now();
|
|
3790
4342
|
if (now - this.#voiceDropWarnedAt < VOICE_DROP_WARN_INTERVAL_MS) return;
|
|
3791
4343
|
this.#voiceDropWarnedAt = now;
|
|
3792
4344
|
this.#logger.warn("voice dropped: listeners are not keeping up", { dropped: this.#voiceDropped });
|
|
@@ -3860,6 +4412,7 @@ var Client = class {
|
|
|
3860
4412
|
this.#status = ClientStatus.Disconnected;
|
|
3861
4413
|
const cause = err ?? new TeamspeakError("connection closed");
|
|
3862
4414
|
this.#lastDrop = cause;
|
|
4415
|
+
this.#endSession(cause);
|
|
3863
4416
|
this.#announceDisconnected(cause);
|
|
3864
4417
|
this.#scheduleReconnect(cause).catch((error) => {
|
|
3865
4418
|
this.#logger.error("reconnecting failed", { error: String(error) });
|
|
@@ -3882,14 +4435,20 @@ var Client = class {
|
|
|
3882
4435
|
this.#reconnecting = true;
|
|
3883
4436
|
const stop = new AbortController();
|
|
3884
4437
|
this.#stopReconnecting = stop;
|
|
4438
|
+
let made = 0;
|
|
4439
|
+
let stoppedBy;
|
|
3885
4440
|
try {
|
|
3886
4441
|
for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
|
|
3887
|
-
if (this.#stopped)
|
|
4442
|
+
if (this.#stopped) {
|
|
4443
|
+
stoppedBy = { cause: void 0 };
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
3888
4446
|
let retry;
|
|
3889
4447
|
try {
|
|
3890
4448
|
retry = shouldRetry(err, attempt);
|
|
3891
4449
|
} catch (error) {
|
|
3892
4450
|
this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
|
|
4451
|
+
stoppedBy = { cause: err };
|
|
3893
4452
|
return;
|
|
3894
4453
|
}
|
|
3895
4454
|
if (!retry) {
|
|
@@ -3897,6 +4456,7 @@ var Client = class {
|
|
|
3897
4456
|
attempt,
|
|
3898
4457
|
reason: err.message
|
|
3899
4458
|
});
|
|
4459
|
+
stoppedBy = { cause: err };
|
|
3900
4460
|
return;
|
|
3901
4461
|
}
|
|
3902
4462
|
const delay = backoffDelay(policy, attempt);
|
|
@@ -3904,14 +4464,23 @@ var Client = class {
|
|
|
3904
4464
|
attempt,
|
|
3905
4465
|
delayMs: delay
|
|
3906
4466
|
});
|
|
4467
|
+
this.#callHook(this.#trace?.reconnectScheduled, () => ({
|
|
4468
|
+
attempt,
|
|
4469
|
+
delayMs: delay,
|
|
4470
|
+
cause: err
|
|
4471
|
+
}));
|
|
3907
4472
|
await sleep(delay, stop.signal);
|
|
3908
|
-
if (this.#stopped)
|
|
4473
|
+
if (this.#stopped) {
|
|
4474
|
+
stoppedBy = { cause: void 0 };
|
|
4475
|
+
return;
|
|
4476
|
+
}
|
|
3909
4477
|
if (this.#status !== ClientStatus.Disconnected) {
|
|
3910
4478
|
this.#logger.info("reconnect loop ended: connected by other means", { attempt });
|
|
3911
4479
|
return;
|
|
3912
4480
|
}
|
|
3913
4481
|
try {
|
|
3914
|
-
|
|
4482
|
+
made++;
|
|
4483
|
+
await this.#connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) }, attempt, true);
|
|
3915
4484
|
if (this.status === ClientStatus.Connected) {
|
|
3916
4485
|
this.#logger.info("reconnected", { attempt });
|
|
3917
4486
|
return;
|
|
@@ -3919,11 +4488,19 @@ var Client = class {
|
|
|
3919
4488
|
err = this.#lastDrop ?? new TeamspeakError("connection closed");
|
|
3920
4489
|
} catch (retryErr) {
|
|
3921
4490
|
if (retryErr instanceof AlreadyConnectedError) return;
|
|
3922
|
-
err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr));
|
|
4491
|
+
err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr), { cause: retryErr });
|
|
3923
4492
|
}
|
|
3924
4493
|
}
|
|
3925
4494
|
this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
|
|
4495
|
+
stoppedBy = { cause: err };
|
|
3926
4496
|
} finally {
|
|
4497
|
+
if (stoppedBy !== void 0) {
|
|
4498
|
+
const { cause } = stoppedBy;
|
|
4499
|
+
this.#callHook(this.#trace?.reconnectStopped, () => ({
|
|
4500
|
+
attempts: made,
|
|
4501
|
+
cause
|
|
4502
|
+
}));
|
|
4503
|
+
}
|
|
3927
4504
|
this.#reconnecting = false;
|
|
3928
4505
|
if (this.#stopReconnecting === stop) this.#stopReconnecting = void 0;
|
|
3929
4506
|
}
|
|
@@ -3944,7 +4521,7 @@ var Client = class {
|
|
|
3944
4521
|
if (this.#status === ClientStatus.Disconnected) return;
|
|
3945
4522
|
this.#status = ClientStatus.Disconnected;
|
|
3946
4523
|
this.#handler.close();
|
|
3947
|
-
this.#announceDisconnected(error instanceof Error ? error : new TeamspeakError(String(error)));
|
|
4524
|
+
this.#announceDisconnected(error instanceof Error ? error : new TeamspeakError(String(error), { cause: error }));
|
|
3948
4525
|
}
|
|
3949
4526
|
};
|
|
3950
4527
|
function kickedEvent(event) {
|
|
@@ -3991,6 +4568,6 @@ async function unsubscribeAllChannels(client, options) {
|
|
|
3991
4568
|
await client.send({ command: "channelunsubscribeall" }, options);
|
|
3992
4569
|
}
|
|
3993
4570
|
//#endregion
|
|
3994
|
-
export { AlreadyConnectedError, Client, ClientStatus,
|
|
4571
|
+
export { AlreadyConnectedError, Client, ClientStatus, ConnectPhase, ConnectionTimeoutError, FileTransferError, HandshakeError, Identity, InvalidAddressError, InvalidIdentityError, RemovedError, backoffDelay, commandChannel, defaultBackoffPolicy, defaultShouldRetry, dialFileTransfer, downloadFileData, generateIdentity, generateIdentitySync, getChannelDescription, packetChannel, parseIdentity, subscribeAllChannels, subscribeChannel, uidFromPublicKey, unknownServerCapabilities, unsubscribeAllChannels, unsubscribeChannel, uploadFileData };
|
|
3995
4572
|
|
|
3996
4573
|
//# sourceMappingURL=index.js.map
|