@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Resolver } from "./resolver.js";
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { resolveSrv } from "node:dns/promises";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import type { ResolvedAddr } from "../types.js";
|
|
4
|
+
import { isIpAddress, joinHostPort, splitHostPort } from "../address.js";
|
|
5
|
+
import { abortable } from "../abort.js";
|
|
6
|
+
|
|
7
|
+
const TS_DNS_DEFAULT_PORT = 41144;
|
|
8
|
+
const NICKNAME_LOOKUP_URL = "https://named.myteamspeak.com/lookup";
|
|
9
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
10
|
+
/**
|
|
11
|
+
* How long resolution may take in all; past it, the address is used as given.
|
|
12
|
+
* A Client holds any resolver, its own or a custom one, to the same budget.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export const RESOLVE_BUDGET_MS = 10_000;
|
|
17
|
+
/** How long one TSDNS server may take to answer. */
|
|
18
|
+
const TSDNS_TIMEOUT_MS = 3_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The most a TSDNS server or the nickname service is read for. Either answers
|
|
22
|
+
* with one short line; without a bound, one that kept sending would be held in
|
|
23
|
+
* memory until the resolution budget ran out.
|
|
24
|
+
*/
|
|
25
|
+
export const MAX_DISCOVERY_RESPONSE: number = 8 * 1024;
|
|
26
|
+
|
|
27
|
+
/** How the default resolver resolves. */
|
|
28
|
+
export interface ResolverOptions {
|
|
29
|
+
/** Look dot-less names up at named.myteamspeak.com; off by default. */
|
|
30
|
+
readonly resolveNicknames?: boolean | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class Resolver {
|
|
34
|
+
readonly #cache = new Map<string, ResolvedAddr[]>();
|
|
35
|
+
readonly #resolveNicknames: boolean;
|
|
36
|
+
|
|
37
|
+
constructor(options: ResolverOptions = {}) {
|
|
38
|
+
this.#resolveNicknames = options.resolveNicknames ?? false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve as the TeamSpeak application does: a server nickname (only with
|
|
43
|
+
* `resolveNicknames`), then SRV, then TSDNS, then the address as given. The
|
|
44
|
+
* whole resolution is bounded: past the budget, or when a step fails, the
|
|
45
|
+
* address as given is the answer.
|
|
46
|
+
*/
|
|
47
|
+
async resolve(
|
|
48
|
+
inputAddr: string,
|
|
49
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
50
|
+
): Promise<ResolvedAddr[]> {
|
|
51
|
+
if (!inputAddr) throw new Error("empty address");
|
|
52
|
+
const budget = AbortSignal.timeout(RESOLVE_BUDGET_MS);
|
|
53
|
+
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget;
|
|
54
|
+
try {
|
|
55
|
+
return await this.#resolve(inputAddr, signal, true);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
options.signal?.throwIfAborted();
|
|
58
|
+
if (!budget.aborted) throw error;
|
|
59
|
+
const { host, port } = splitHostPort(inputAddr);
|
|
60
|
+
return [{ addr: joinHostPort(host, port), source: "Direct" }];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #resolve(
|
|
65
|
+
inputAddr: string,
|
|
66
|
+
signal: AbortSignal,
|
|
67
|
+
nicknames: boolean,
|
|
68
|
+
): Promise<ResolvedAddr[]> {
|
|
69
|
+
const cached = this.#getValidCache(inputAddr);
|
|
70
|
+
if (cached) return cached;
|
|
71
|
+
|
|
72
|
+
const { host, port } = splitHostPort(inputAddr);
|
|
73
|
+
|
|
74
|
+
if (isIpAddress(host)) {
|
|
75
|
+
return [{ addr: joinHostPort(host, port), source: "Direct" }];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A dot-less name may be a server nickname, which only the TeamSpeak
|
|
79
|
+
// service can answer: asking it sends the name to a third party, so it is
|
|
80
|
+
// opt-in. Otherwise the name is resolved like any host.
|
|
81
|
+
if (nicknames && this.#resolveNicknames && !host.includes(".") && host !== "localhost") {
|
|
82
|
+
const nickAddr = await resolveNickname(host, signal);
|
|
83
|
+
signal.throwIfAborted();
|
|
84
|
+
if (nickAddr) return this.#resolve(nickAddr, signal, false);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// SRV _ts3._udp.<host>
|
|
88
|
+
const srvResults = await this.#resolveSRV(host, signal);
|
|
89
|
+
if (srvResults) return this.#setCache(inputAddr, srvResults);
|
|
90
|
+
|
|
91
|
+
const domainList = getDomainList(host);
|
|
92
|
+
|
|
93
|
+
// TSDNS via SRV _tsdns._tcp.<domain>
|
|
94
|
+
const tsdnsSrv = await this.#resolveTSDNSSRV(domainList, host, signal);
|
|
95
|
+
if (tsdnsSrv) {
|
|
96
|
+
return this.#setCache(inputAddr, [{ addr: tsdnsSrv, source: "TSDNS-SRV" }]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// TSDNS direct :41144
|
|
100
|
+
const tsdnsDirect = await this.#resolveTSDNSDirect(domainList, host, signal);
|
|
101
|
+
if (tsdnsDirect) {
|
|
102
|
+
return this.#setCache(inputAddr, [{ addr: tsdnsDirect, source: "TSDNS-Direct" }]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
signal.throwIfAborted();
|
|
106
|
+
// Plain DNS fallback
|
|
107
|
+
const fallback: ResolvedAddr[] = [{ addr: joinHostPort(host, port), source: "Direct" }];
|
|
108
|
+
return this.#setCache(inputAddr, fallback);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#getValidCache(inputAddr: string): ResolvedAddr[] | null {
|
|
112
|
+
const cached = this.#cache.get(inputAddr);
|
|
113
|
+
if (!cached || cached.length === 0) return null;
|
|
114
|
+
const first = cached[0]!;
|
|
115
|
+
if (first.expiry !== undefined && Date.now() > first.expiry.getTime()) return null;
|
|
116
|
+
return cached;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#setCache(key: string, results: ResolvedAddr[]): ResolvedAddr[] {
|
|
120
|
+
const expiry = new Date(Date.now() + CACHE_TTL_MS);
|
|
121
|
+
const withExpiry = results.map((r) => ({ ...r, expiry }));
|
|
122
|
+
this.#cache.set(key, withExpiry);
|
|
123
|
+
return withExpiry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #resolveSRV(host: string, signal?: AbortSignal): Promise<ResolvedAddr[] | null> {
|
|
127
|
+
try {
|
|
128
|
+
const srvs = await abortable(resolveSrv(`_ts3._udp.${host}`), signal);
|
|
129
|
+
if (!srvs || srvs.length === 0) return null;
|
|
130
|
+
return srvs.map((srv) => ({
|
|
131
|
+
addr: joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)),
|
|
132
|
+
source: "SRV",
|
|
133
|
+
}));
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async #resolveTSDNSSRV(
|
|
140
|
+
domains: string[],
|
|
141
|
+
queryHost: string,
|
|
142
|
+
signal?: AbortSignal,
|
|
143
|
+
): Promise<string | null> {
|
|
144
|
+
for (const domain of domains) {
|
|
145
|
+
try {
|
|
146
|
+
const srvs = await abortable(resolveSrv(`_tsdns._tcp.${domain}`), signal);
|
|
147
|
+
if (!srvs || srvs.length === 0) continue;
|
|
148
|
+
for (const srv of srvs) {
|
|
149
|
+
const tsdnsAddr = joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port));
|
|
150
|
+
const result = await queryTSDNS(tsdnsAddr, queryHost, signal);
|
|
151
|
+
if (result) return result;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async #resolveTSDNSDirect(
|
|
161
|
+
domains: string[],
|
|
162
|
+
queryHost: string,
|
|
163
|
+
signal?: AbortSignal,
|
|
164
|
+
): Promise<string | null> {
|
|
165
|
+
// Asked at once rather than in turn, so the probes cost one timeout in
|
|
166
|
+
// all; the most specific domain's answer wins.
|
|
167
|
+
const answers = await Promise.all(
|
|
168
|
+
domains.map((domain) =>
|
|
169
|
+
queryTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, signal),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
return answers.find((answer) => answer !== null) ?? null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Helpers ----------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** @internal Ask the nickname service for `nickname`'s address. */
|
|
179
|
+
export async function resolveNickname(
|
|
180
|
+
nickname: string,
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
): Promise<string | null> {
|
|
183
|
+
try {
|
|
184
|
+
const url = new URL(NICKNAME_LOOKUP_URL);
|
|
185
|
+
url.searchParams.set("name", nickname);
|
|
186
|
+
const init: RequestInit = signal ? { signal } : {};
|
|
187
|
+
const resp = await fetch(url.toString(), init);
|
|
188
|
+
if (!resp.ok) return null;
|
|
189
|
+
const text = await readLimited(resp, MAX_DISCOVERY_RESPONSE);
|
|
190
|
+
const line = text.split("\n")[0]?.trim();
|
|
191
|
+
return line || null;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A response body's first `limit` bytes, as text; the rest is not read. */
|
|
198
|
+
async function readLimited(resp: Response, limit: number): Promise<string> {
|
|
199
|
+
if (resp.body === null) return "";
|
|
200
|
+
const reader = resp.body.getReader();
|
|
201
|
+
const chunks: Uint8Array[] = [];
|
|
202
|
+
let size = 0;
|
|
203
|
+
try {
|
|
204
|
+
while (size < limit) {
|
|
205
|
+
const { done, value } = await reader.read();
|
|
206
|
+
if (done) break;
|
|
207
|
+
const part = value.subarray(0, limit - size);
|
|
208
|
+
chunks.push(part);
|
|
209
|
+
size += part.length;
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
reader.cancel().catch(() => {});
|
|
213
|
+
}
|
|
214
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @internal Ask the TSDNS server at `tsdnsAddr` for `queryHost`'s address. */
|
|
218
|
+
export function queryTSDNS(
|
|
219
|
+
tsdnsAddr: string,
|
|
220
|
+
queryHost: string,
|
|
221
|
+
signal?: AbortSignal,
|
|
222
|
+
): Promise<string | null> {
|
|
223
|
+
const [host, portStr] = splitHostPortParts(tsdnsAddr);
|
|
224
|
+
const port = parseInt(portStr, 10);
|
|
225
|
+
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
if (signal?.aborted) return resolve(null);
|
|
228
|
+
const finish = (answer: string | null): void => {
|
|
229
|
+
clearTimeout(timeout);
|
|
230
|
+
signal?.removeEventListener("abort", onAbort);
|
|
231
|
+
socket.destroy();
|
|
232
|
+
resolve(answer);
|
|
233
|
+
};
|
|
234
|
+
const onAbort = (): void => finish(null);
|
|
235
|
+
const timeout = setTimeout(() => finish(null), TSDNS_TIMEOUT_MS);
|
|
236
|
+
|
|
237
|
+
const socket = createConnection({ host, port, timeout: 2_000 }, () => {
|
|
238
|
+
socket.write(`${queryHost}\n`);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
let buf = "";
|
|
242
|
+
socket.on("data", (chunk: Buffer) => {
|
|
243
|
+
buf += chunk.toString("latin1");
|
|
244
|
+
const idx = buf.indexOf("\n");
|
|
245
|
+
if (idx >= 0 && idx < MAX_DISCOVERY_RESPONSE) {
|
|
246
|
+
const line = Buffer.from(buf.slice(0, idx), "latin1").toString("utf8").trim();
|
|
247
|
+
finish(!line || line === "404" || line === "errors" ? null : line);
|
|
248
|
+
} else if (buf.length >= MAX_DISCOVERY_RESPONSE) {
|
|
249
|
+
// No answer is that long.
|
|
250
|
+
finish(null);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
socket.on("error", () => finish(null));
|
|
255
|
+
socket.on("timeout", () => finish(null));
|
|
256
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function splitHostPortParts(addr: string): [string, string] {
|
|
261
|
+
const { host, port } = splitHostPort(addr);
|
|
262
|
+
return [host, port];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getDomainList(host: string): string[] {
|
|
266
|
+
const parts = host.split(".");
|
|
267
|
+
const list: string[] = [];
|
|
268
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
269
|
+
list.push(parts.slice(i).join("."));
|
|
270
|
+
}
|
|
271
|
+
return list.slice(0, 3);
|
|
272
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Reason, TeamspeakError } from "@honeybbq/teamspeak-core";
|
|
2
|
+
|
|
3
|
+
/** `connect()` was called on a client that is connecting or connected. */
|
|
4
|
+
export class AlreadyConnectedError extends TeamspeakError {
|
|
5
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
6
|
+
static override readonly kind: string = "AlreadyConnectedError";
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super("already connecting or connected");
|
|
10
|
+
this.name = "AlreadyConnectedError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The address a client was given cannot be connected to: no host, a host with
|
|
16
|
+
* characters no host name or IP address has, or a port outside 1–65535.
|
|
17
|
+
* `connect()` rejects with it before it starts, and the client stays
|
|
18
|
+
* disconnected. A name that does not resolve is not this: that fails with a
|
|
19
|
+
* TeamspeakError whose `cause` is the lookup's error, since DNS failures are
|
|
20
|
+
* often transient.
|
|
21
|
+
*/
|
|
22
|
+
export class InvalidAddressError extends TeamspeakError {
|
|
23
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
24
|
+
static override readonly kind: string = "InvalidAddressError";
|
|
25
|
+
|
|
26
|
+
/** The address as given. */
|
|
27
|
+
readonly address: string;
|
|
28
|
+
|
|
29
|
+
constructor(address: string, reason: string) {
|
|
30
|
+
super(`invalid address ${JSON.stringify(address)}: ${reason}`);
|
|
31
|
+
this.name = "InvalidAddressError";
|
|
32
|
+
this.address = address;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A packet failed authentication: it was corrupted, or not from the server.
|
|
38
|
+
* Internal: the packet is dropped, and this never reaches a caller.
|
|
39
|
+
*/
|
|
40
|
+
export class EAXTagMismatchError extends TeamspeakError {
|
|
41
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
42
|
+
static override readonly kind: string = "EAXTagMismatchError";
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super("EAX tag mismatch");
|
|
46
|
+
this.name = "EAXTagMismatchError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An unencrypted packet failed its integrity check. Internal: the packet is
|
|
52
|
+
* dropped, and this never reaches a caller.
|
|
53
|
+
*/
|
|
54
|
+
export class FakeSignatureMismatchError extends TeamspeakError {
|
|
55
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
56
|
+
static override readonly kind: string = "FakeSignatureMismatchError";
|
|
57
|
+
|
|
58
|
+
constructor() {
|
|
59
|
+
super("fake signature mismatch");
|
|
60
|
+
this.name = "FakeSignatureMismatchError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A file transfer connection failed: it could not be opened, or broke during the transfer. */
|
|
65
|
+
export class FileTransferError extends TeamspeakError {
|
|
66
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
67
|
+
static override readonly kind: string = "FileTransferError";
|
|
68
|
+
|
|
69
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
70
|
+
super(message, options);
|
|
71
|
+
this.name = "FileTransferError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The handshake failed on what the server sent: a malformed or out-of-range
|
|
77
|
+
* Init1 step, public key, license or proof. `connect()` rejects with it, the
|
|
78
|
+
* underlying failure as its `cause`.
|
|
79
|
+
*/
|
|
80
|
+
export class CryptoInitError extends TeamspeakError {
|
|
81
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
82
|
+
static override readonly kind: string = "CryptoInitError";
|
|
83
|
+
|
|
84
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
85
|
+
super(message, options);
|
|
86
|
+
this.name = "CryptoInitError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An identity string `parseIdentity()` cannot read. */
|
|
91
|
+
export class InvalidIdentityError extends TeamspeakError {
|
|
92
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
93
|
+
static override readonly kind: string = "InvalidIdentityError";
|
|
94
|
+
|
|
95
|
+
constructor(message = "invalid identity format", options?: ErrorOptions) {
|
|
96
|
+
super(message, options);
|
|
97
|
+
this.name = "InvalidIdentityError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The server stopped answering: nothing arrived, or a packet went
|
|
103
|
+
* unacknowledged, for a minute.
|
|
104
|
+
*/
|
|
105
|
+
export class ConnectionTimeoutError extends TeamspeakError {
|
|
106
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
107
|
+
static override readonly kind: string = "ConnectionTimeoutError";
|
|
108
|
+
|
|
109
|
+
constructor() {
|
|
110
|
+
super("connection timed out: the server stopped answering");
|
|
111
|
+
this.name = "ConnectionTimeoutError";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The server ended this client's session: a kick, a ban, or the server
|
|
117
|
+
* stopping. The default reconnect policy retries a server that stopped, but
|
|
118
|
+
* not a kick or a ban.
|
|
119
|
+
*/
|
|
120
|
+
export class RemovedError extends TeamspeakError {
|
|
121
|
+
/** {@inheritDoc TeamspeakError.kind} */
|
|
122
|
+
static override readonly kind: string = "RemovedError";
|
|
123
|
+
|
|
124
|
+
/** Why: `Reason.KickServer`, `KickServerBan`, `ServerStop` or `ServerShutdown`. */
|
|
125
|
+
readonly reason: number;
|
|
126
|
+
/** The reason the server or the invoker gave, if any. */
|
|
127
|
+
readonly reasonMessage: string;
|
|
128
|
+
/** Who removed the client, for a kick or a ban. */
|
|
129
|
+
readonly invokerName: string;
|
|
130
|
+
|
|
131
|
+
constructor(reason: number, reasonMessage: string, invokerName: string) {
|
|
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
|
+
|
|
143
|
+
function reasonName(reason: number): string {
|
|
144
|
+
switch (reason) {
|
|
145
|
+
case Reason.KickServer:
|
|
146
|
+
return "kicked from server";
|
|
147
|
+
case Reason.KickServerBan:
|
|
148
|
+
return "banned from server";
|
|
149
|
+
case Reason.ServerStop:
|
|
150
|
+
return "server stopped";
|
|
151
|
+
case Reason.ServerShutdown:
|
|
152
|
+
return "server shut down";
|
|
153
|
+
default:
|
|
154
|
+
return `reason ${reason}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EventMiddleware, ServerEvent } from "./types.js";
|
|
2
|
+
import type { CommandExecutor, CommandMiddleware } from "@honeybbq/teamspeak-core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compose a chain of middlewares around a base handler.
|
|
6
|
+
* Rightmost middleware wraps the base first.
|
|
7
|
+
*/
|
|
8
|
+
export function buildCommandChain(
|
|
9
|
+
middlewares: readonly CommandMiddleware[],
|
|
10
|
+
base: CommandExecutor["send"],
|
|
11
|
+
): CommandExecutor["send"] {
|
|
12
|
+
let handler = base;
|
|
13
|
+
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
14
|
+
handler = middlewares[i]!(handler);
|
|
15
|
+
}
|
|
16
|
+
return handler;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildEventChain(
|
|
20
|
+
middlewares: EventMiddleware[],
|
|
21
|
+
base: (event: ServerEvent) => void,
|
|
22
|
+
): (event: ServerEvent) => void {
|
|
23
|
+
let handler = base;
|
|
24
|
+
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
25
|
+
handler = middlewares[i]!(handler);
|
|
26
|
+
}
|
|
27
|
+
return handler;
|
|
28
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { buildCommandOrdered } from "@honeybbq/teamspeak-core/internal";
|
|
3
|
+
import type { Crypt } from "../crypto/crypt.js";
|
|
4
|
+
import { CryptoInitError } from "../errors.js";
|
|
5
|
+
|
|
6
|
+
export const INIT_VERSION = 1566914096; // 3.5.0 [Stable]
|
|
7
|
+
|
|
8
|
+
const INIT_VERSION_LEN = 4;
|
|
9
|
+
const INIT_TYPE_LEN = 1;
|
|
10
|
+
const INIT_STEP_LEN = 21;
|
|
11
|
+
|
|
12
|
+
/** The RSA challenge each connection attempt, by its Crypt, has answered. */
|
|
13
|
+
const answeredChallenges = new WeakMap<
|
|
14
|
+
Crypt,
|
|
15
|
+
{ readonly challenge: Uint8Array; readonly answer: Uint8Array }
|
|
16
|
+
>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle the TS3INIT1 handshake steps.
|
|
20
|
+
* Returns the response bytes to send, or null if nothing should be sent.
|
|
21
|
+
* Throws a CryptoInitError for a step that cannot be answered.
|
|
22
|
+
*/
|
|
23
|
+
export function processInit1(crypt: Crypt, data: Uint8Array | null): Uint8Array | null {
|
|
24
|
+
if (data === null || (data.length >= 1 && data[0] === 0x7f)) {
|
|
25
|
+
return buildInit1StartPacket();
|
|
26
|
+
}
|
|
27
|
+
if (data.length === 0) throw new CryptoInitError("empty Init1 packet");
|
|
28
|
+
|
|
29
|
+
switch (data[0]) {
|
|
30
|
+
case 0:
|
|
31
|
+
return buildInit1Step1Packet(data);
|
|
32
|
+
case 1:
|
|
33
|
+
return buildInit1Step2Packet(data);
|
|
34
|
+
case 2:
|
|
35
|
+
return buildInit1Step3Packet(data);
|
|
36
|
+
case 3:
|
|
37
|
+
return buildInit1Step4Packet(crypt, data);
|
|
38
|
+
default:
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildInit1StartPacket(): Uint8Array {
|
|
44
|
+
const sendData = new Uint8Array(INIT_VERSION_LEN + INIT_TYPE_LEN + 4 + 4 + 8);
|
|
45
|
+
const view = new DataView(sendData.buffer);
|
|
46
|
+
|
|
47
|
+
view.setUint32(0, INIT_VERSION, false);
|
|
48
|
+
sendData[4] = 0x00;
|
|
49
|
+
|
|
50
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
51
|
+
const clampedNow = Math.max(0, Math.min(nowSec, 0xffff_ffff));
|
|
52
|
+
view.setUint32(5, clampedNow, false);
|
|
53
|
+
|
|
54
|
+
const rng = randomBytes(4);
|
|
55
|
+
sendData.set(rng, 9);
|
|
56
|
+
|
|
57
|
+
return sendData;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildInit1Step1Packet(data: Uint8Array): Uint8Array | null {
|
|
61
|
+
if (data.length !== INIT_STEP_LEN) return null;
|
|
62
|
+
|
|
63
|
+
const sendData = new Uint8Array(INIT_TYPE_LEN + 16 + 4);
|
|
64
|
+
sendData[0] = 0x01;
|
|
65
|
+
|
|
66
|
+
// TS rand: little-endian uint32 at offset [INIT_VERSION_LEN + INIT_TYPE_LEN + 4]
|
|
67
|
+
const tsRandOffset = INIT_VERSION_LEN + INIT_TYPE_LEN + 4;
|
|
68
|
+
const tsRand =
|
|
69
|
+
data[tsRandOffset]! |
|
|
70
|
+
(data[tsRandOffset + 1]! << 8) |
|
|
71
|
+
(data[tsRandOffset + 2]! << 16) |
|
|
72
|
+
(data[tsRandOffset + 3]! << 24);
|
|
73
|
+
|
|
74
|
+
new DataView(sendData.buffer).setUint32(INIT_TYPE_LEN + 16, tsRand >>> 0, false);
|
|
75
|
+
return sendData;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildInit1Step2Packet(data: Uint8Array): Uint8Array | null {
|
|
79
|
+
if (data.length !== INIT_STEP_LEN) return null;
|
|
80
|
+
|
|
81
|
+
const sendData = new Uint8Array(INIT_VERSION_LEN + INIT_TYPE_LEN + 16 + 4);
|
|
82
|
+
new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
|
|
83
|
+
sendData[4] = 0x02;
|
|
84
|
+
sendData.set(data.slice(1, 21), 5);
|
|
85
|
+
return sendData;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildInit1Step3Packet(data: Uint8Array): Uint8Array | null {
|
|
89
|
+
if (data.length !== INIT_VERSION_LEN + INIT_TYPE_LEN + 16 + 4) return null;
|
|
90
|
+
|
|
91
|
+
const sendData = new Uint8Array(INIT_TYPE_LEN + 64 + 64 + 4 + 100);
|
|
92
|
+
sendData[0] = 0x03;
|
|
93
|
+
sendData[INIT_TYPE_LEN + 64 - 1] = 1;
|
|
94
|
+
sendData[INIT_TYPE_LEN + 64 + 64 - 1] = 1;
|
|
95
|
+
new DataView(sendData.buffer).setUint32(INIT_TYPE_LEN + 64 + 64, 1, false);
|
|
96
|
+
return sendData;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildInit1Step4Packet(crypt: Crypt, data: Uint8Array): Uint8Array | null {
|
|
100
|
+
if (data.length !== INIT_TYPE_LEN + 64 + 64 + 4 + 100) return null;
|
|
101
|
+
// One challenge per connection attempt, whose Crypt is its own. Each answer
|
|
102
|
+
// costs up to a second of synchronous squarings, and a second would choose a
|
|
103
|
+
// new alpha behind the clientinitiv already sent. The same challenge again
|
|
104
|
+
// means our answer was lost — the transport does not resend it — so it gets
|
|
105
|
+
// the same answer, without solving anything; a different one gets none.
|
|
106
|
+
const answered = answeredChallenges.get(crypt);
|
|
107
|
+
if (answered !== undefined) {
|
|
108
|
+
return Buffer.from(answered.challenge).equals(Buffer.from(data)) ? answered.answer : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const level = new DataView(data.buffer, data.byteOffset).getUint32(1 + 128, false);
|
|
112
|
+
const y = crypt.solveRsaChallenge(data, 1, level);
|
|
113
|
+
|
|
114
|
+
const alphaB64 = Buffer.from(crypt.newAlpha()).toString("base64");
|
|
115
|
+
const omegaB64 = crypt.identity.publicKey();
|
|
116
|
+
|
|
117
|
+
const cmd = buildCommandOrdered("clientinitiv", [
|
|
118
|
+
["alpha", alphaB64],
|
|
119
|
+
["omega", omegaB64],
|
|
120
|
+
["ot", "1"],
|
|
121
|
+
["ip", ""],
|
|
122
|
+
]);
|
|
123
|
+
const cmdBytes = Buffer.from(cmd);
|
|
124
|
+
|
|
125
|
+
const sendData = new Uint8Array(INIT_VERSION_LEN + INIT_TYPE_LEN + 232 + 64 + cmdBytes.length);
|
|
126
|
+
const view = new DataView(sendData.buffer);
|
|
127
|
+
view.setUint32(0, INIT_VERSION, false);
|
|
128
|
+
sendData[4] = 0x04;
|
|
129
|
+
sendData.set(data.slice(1, 233), 5);
|
|
130
|
+
sendData.set(y.slice(0, 64), 5 + 232);
|
|
131
|
+
sendData.set(cmdBytes, 5 + 232 + 64);
|
|
132
|
+
|
|
133
|
+
answeredChallenges.set(crypt, { challenge: Uint8Array.from(data), answer: sendData });
|
|
134
|
+
return sendData;
|
|
135
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { KeyObject } from "node:crypto";
|
|
2
|
+
import type { Crypt } from "../crypto/crypt.js";
|
|
3
|
+
import { importPublicKey } from "../crypto/identity.js";
|
|
4
|
+
import { verifySign, getSharedSecret2 } from "../crypto/primitives.js";
|
|
5
|
+
import { parseLicenses } from "./license.js";
|
|
6
|
+
import { CryptoInitError } from "../errors.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CryptoInit2 performs the second stage of crypto initialization (Ed25519 ECDH).
|
|
10
|
+
* Mirrors Go's handshake.CryptoInit2.
|
|
11
|
+
*/
|
|
12
|
+
export function cryptoInit2(
|
|
13
|
+
crypt: Crypt,
|
|
14
|
+
license: string,
|
|
15
|
+
omega: string,
|
|
16
|
+
proof: string,
|
|
17
|
+
beta: string,
|
|
18
|
+
privateKey: Uint8Array,
|
|
19
|
+
): void {
|
|
20
|
+
if (crypt.alpha.length === 0) {
|
|
21
|
+
throw new CryptoInitError("initivexpand2 arrived before clientinitiv was sent");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const licenseBytes = Buffer.from(license, "base64");
|
|
25
|
+
const omegaBytes = Buffer.from(omega, "base64");
|
|
26
|
+
const proofBytes = Buffer.from(proof, "base64");
|
|
27
|
+
const betaBytes = Buffer.from(beta, "base64");
|
|
28
|
+
|
|
29
|
+
let serverPubKey: KeyObject;
|
|
30
|
+
try {
|
|
31
|
+
serverPubKey = importPublicKey(omegaBytes);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new CryptoInitError("the server's public key is invalid", { cause: error });
|
|
34
|
+
}
|
|
35
|
+
if (!verifySign(serverPubKey, licenseBytes, proofBytes)) {
|
|
36
|
+
throw new CryptoInitError("the server's license proof is not valid");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let sharedSecret: Uint8Array;
|
|
40
|
+
try {
|
|
41
|
+
const key = parseLicenses(licenseBytes).deriveKey();
|
|
42
|
+
sharedSecret = getSharedSecret2(key, privateKey);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new CryptoInitError("the server's license is invalid", { cause: error });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
crypt.setSharedSecret(crypt.alpha, betaBytes, sharedSecret);
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
3
|
+
import { MAX_LICENSE_BLOCKS, parseLicenses } from "./license.js";
|
|
4
|
+
|
|
5
|
+
const BASE_POINT = ed25519.Point.BASE.toBytes();
|
|
6
|
+
const EPHEMERAL_BLOCK_LEN = 42;
|
|
7
|
+
|
|
8
|
+
/** A version byte and `count` ephemeral blocks, the smallest a block can be. */
|
|
9
|
+
function ephemeralChain(count: number): Uint8Array {
|
|
10
|
+
const data = new Uint8Array(1 + count * EPHEMERAL_BLOCK_LEN);
|
|
11
|
+
data[0] = 1;
|
|
12
|
+
for (let i = 0; i < count; i++) {
|
|
13
|
+
const offset = 1 + i * EPHEMERAL_BLOCK_LEN;
|
|
14
|
+
data[offset] = 0; // key kind
|
|
15
|
+
data.set(BASE_POINT, offset + 1);
|
|
16
|
+
data[offset + 33] = 32; // ephemeral
|
|
17
|
+
}
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("parseLicenses", () => {
|
|
22
|
+
it(`takes a chain of up to ${MAX_LICENSE_BLOCKS} blocks`, () => {
|
|
23
|
+
const chain = parseLicenses(ephemeralChain(MAX_LICENSE_BLOCKS));
|
|
24
|
+
expect(chain.blocks).toHaveLength(MAX_LICENSE_BLOCKS);
|
|
25
|
+
expect(chain.deriveKey()).toHaveLength(32);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Every block costs a point multiplication to derive: a thousand of them
|
|
29
|
+
// held the event loop for seconds.
|
|
30
|
+
it("rejects a longer chain before deriving anything", () => {
|
|
31
|
+
expect(() => parseLicenses(ephemeralChain(MAX_LICENSE_BLOCKS + 1))).toThrow(/longer than/);
|
|
32
|
+
expect(() => parseLicenses(ephemeralChain(1000))).toThrow(/longer than/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects a block that declares more than the license holds", () => {
|
|
36
|
+
// A TS5 server block whose property count, at byte 43, is cut off.
|
|
37
|
+
const ts5 = ephemeralChain(1);
|
|
38
|
+
ts5[34] = 8;
|
|
39
|
+
expect(() => parseLicenses(ts5)).toThrow(/longer than the license/);
|
|
40
|
+
|
|
41
|
+
// One whose property claims 200 bytes when 1 follows.
|
|
42
|
+
const property = new Uint8Array(1 + 42 + 4);
|
|
43
|
+
property.set(ephemeralChain(1));
|
|
44
|
+
property[34] = 8;
|
|
45
|
+
property[44] = 1; // one property
|
|
46
|
+
property[45] = 200;
|
|
47
|
+
expect(() => parseLicenses(property)).toThrow(/too short/);
|
|
48
|
+
});
|
|
49
|
+
});
|