@honeybbq/teamspeak 1.0.0-beta.21 → 1.0.0-beta.23
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 +2 -1
- package/dist/client/crypto/identity.d.ts +15 -4
- package/dist/client/crypto/identity.d.ts.map +1 -1
- package/dist/client/crypto/identity.js +19 -1
- package/dist/client/crypto/identity.js.map +1 -1
- package/dist/client/crypto/index.d.ts +1 -1
- package/dist/client/crypto/index.d.ts.map +1 -1
- package/dist/client/crypto/index.js +1 -0
- package/dist/client/crypto/index.js.map +1 -1
- package/dist/client/discovery/resolver.d.ts +35 -2
- package/dist/client/discovery/resolver.d.ts.map +1 -1
- package/dist/client/discovery/resolver.js +129 -45
- package/dist/client/discovery/resolver.js.map +1 -1
- package/dist/client/handshake/license.js +1 -0
- package/dist/client/handshake/license.js.map +1 -1
- package/dist/client/index.d.ts +8 -5
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/trace.d.ts +4 -1
- package/dist/client/trace.d.ts.map +1 -1
- package/dist/client/transfer.d.ts +10 -1
- package/dist/client/transfer.d.ts.map +1 -1
- package/dist/client/transfer.js +10 -0
- package/dist/client/transfer.js.map +1 -1
- package/dist/client/transport/handler.d.ts.map +1 -1
- package/dist/client/transport/handler.js +3 -3
- package/dist/client/transport/handler.js.map +1 -1
- package/dist/client/transport/packet.d.ts +5 -0
- package/dist/client/transport/packet.d.ts.map +1 -1
- package/dist/client/transport/packet.js +9 -1
- package/dist/client/transport/packet.js.map +1 -1
- package/dist/client/types.d.ts +28 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +21 -0
- package/dist/client/types.js.map +1 -1
- package/dist/client/view.d.ts +24 -8
- package/dist/client/view.d.ts.map +1 -1
- package/dist/client/view.js +24 -1
- package/dist/client/view.js.map +1 -1
- package/dist/core/api/channels.d.ts +3 -3
- package/dist/core/api/channels.d.ts.map +1 -1
- package/dist/core/api/clients.d.ts +13 -1
- package/dist/core/api/clients.d.ts.map +1 -1
- package/dist/core/api/clients.js +12 -0
- package/dist/core/api/clients.js.map +1 -1
- package/dist/core/api/command.d.ts +11 -3
- package/dist/core/api/command.d.ts.map +1 -1
- package/dist/core/api/command.js +8 -2
- package/dist/core/api/command.js.map +1 -1
- package/dist/core/api/groups.d.ts +2 -1
- package/dist/core/api/groups.d.ts.map +1 -1
- package/dist/core/api/groups.js.map +1 -1
- package/dist/core/api/instance.d.ts +5 -3
- package/dist/core/api/instance.d.ts.map +1 -1
- package/dist/core/api/instance.js.map +1 -1
- package/dist/core/api/server.d.ts +40 -10
- package/dist/core/api/server.d.ts.map +1 -1
- package/dist/core/api/server.js +2 -2
- package/dist/core/api/server.js.map +1 -1
- package/dist/core/command-name.d.ts +6 -0
- package/dist/core/command-name.d.ts.map +1 -1
- package/dist/core/command-name.js +6 -0
- package/dist/core/command-name.js.map +1 -1
- package/dist/core/enums.d.ts +94 -4
- package/dist/core/enums.d.ts.map +1 -1
- package/dist/core/enums.js +91 -4
- package/dist/core/enums.js.map +1 -1
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/property-name.d.ts +3 -3
- package/dist/core/property-name.d.ts.map +1 -1
- package/dist/core/property-name.js +3 -3
- package/dist/core/property-name.js.map +1 -1
- package/dist/core/types.d.ts +14 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +16 -4
- package/dist/core/types.js.map +1 -1
- package/dist/query/client.d.ts +55 -1
- package/dist/query/client.d.ts.map +1 -1
- package/dist/query/client.js +53 -0
- package/dist/query/client.js.map +1 -1
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/index.js.map +1 -1
- package/dist/query/options.d.ts +21 -1
- package/dist/query/options.d.ts.map +1 -1
- package/dist/query/options.js +34 -5
- package/dist/query/options.js.map +1 -1
- package/dist/stream/index.d.ts +2 -1
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +1 -0
- package/dist/stream/index.js.map +1 -1
- package/dist/stream/types.d.ts +243 -25
- package/dist/stream/types.d.ts.map +1 -1
- package/dist/stream/types.js +235 -1
- package/dist/stream/types.js.map +1 -1
- package/package.json +12 -10
- package/src/client/crypto/identity.ts +16 -5
- package/src/client/crypto/index.ts +1 -1
- package/src/client/discovery/resolver.ts +161 -53
- package/src/client/handshake/license.ts +1 -0
- package/src/client/index.ts +13 -5
- package/src/client/trace.ts +4 -1
- package/src/client/transfer.ts +11 -1
- package/src/client/transport/handler.ts +3 -2
- package/src/client/transport/packet.ts +10 -1
- package/src/client/types.ts +30 -1
- package/src/client/view.ts +24 -9
- package/src/core/api/channels.ts +3 -3
- package/src/core/api/clients.ts +14 -1
- package/src/core/api/command.ts +16 -5
- package/src/core/api/groups.ts +2 -1
- package/src/core/api/instance.ts +5 -3
- package/src/core/api/server.ts +39 -10
- package/src/core/command-name.ts +6 -0
- package/src/core/enums.ts +111 -4
- package/src/core/index.ts +12 -2
- package/src/core/property-name.ts +3 -3
- package/src/core/types.ts +19 -8
- package/src/query/client.ts +68 -6
- package/src/query/index.ts +1 -2
- package/src/query/options.ts +26 -13
- package/src/stream/index.ts +14 -8
- package/src/stream/types.ts +321 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@honeybbq/teamspeak",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.23",
|
|
4
4
|
"description": "TeamSpeak SDK for TypeScript: an independent voice client for Node.js, and the typed commands, ids and errors every connection shares",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"client",
|
|
@@ -71,23 +71,24 @@
|
|
|
71
71
|
"@changesets/changelog-github": "^1.0.1",
|
|
72
72
|
"@changesets/cli": "^3.0.3",
|
|
73
73
|
"@honeybbq/teamspeak": "link:.",
|
|
74
|
-
"@microsoft/api-extractor": "7.
|
|
75
|
-
"@types/node": "^26.6.
|
|
74
|
+
"@microsoft/api-extractor": "7.60.1",
|
|
75
|
+
"@types/node": "^26.6.6",
|
|
76
76
|
"@types/ssh2": "^1.15.6",
|
|
77
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
77
78
|
"fast-check": "^4.10.2",
|
|
78
|
-
"oxfmt": "^0.
|
|
79
|
-
"oxlint": "^1.
|
|
80
|
-
"oxlint-tsgolint": "^7.0.
|
|
79
|
+
"oxfmt": "^0.75.0",
|
|
80
|
+
"oxlint": "^1.90.0",
|
|
81
|
+
"oxlint-tsgolint": "^7.0.2005",
|
|
81
82
|
"publint": "^0.3.25",
|
|
82
83
|
"ssh2": "^1.17.0",
|
|
83
|
-
"tsx": "^4.
|
|
84
|
+
"tsx": "^4.23.15",
|
|
84
85
|
"typedoc": "0.28.20",
|
|
85
86
|
"typedoc-material-theme": "^1.4.1",
|
|
86
87
|
"typescript": "^7.0.2",
|
|
87
|
-
"typescript-api": "npm:typescript@^
|
|
88
|
-
"vite": "^8.3.
|
|
88
|
+
"typescript-api": "npm:typescript@^6.0.3",
|
|
89
|
+
"vite": "^8.3.5",
|
|
89
90
|
"vitest": "^5.0.5",
|
|
90
|
-
"werift": "^0.25.
|
|
91
|
+
"werift": "^0.25.2"
|
|
91
92
|
},
|
|
92
93
|
"peerDependencies": {
|
|
93
94
|
"ssh2": "^1.17.0"
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"scripts": {
|
|
104
105
|
"build": "tsc -p tsconfig.build.json",
|
|
105
106
|
"test": "vitest run --project @honeybbq/teamspeak --project scripts",
|
|
107
|
+
"test:coverage": "vitest run --coverage --project @honeybbq/teamspeak --project scripts",
|
|
106
108
|
"test:watch": "vitest",
|
|
107
109
|
"lint": "oxlint --deny-warnings .",
|
|
108
110
|
"fmt": "oxfmt .",
|
|
@@ -246,6 +246,13 @@ function generateKey(): KeyObject {
|
|
|
246
246
|
return generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/** The values of the table below, before freezing. */
|
|
250
|
+
const uidAlgorithmValues = {
|
|
251
|
+
/** SHA-1, 20 bytes: a TeamSpeak 3 server. */
|
|
252
|
+
SHA1: "sha1",
|
|
253
|
+
/** SHA-256, 32 bytes: a TeamSpeak 6 server. */
|
|
254
|
+
SHA256: "sha256",
|
|
255
|
+
} as const;
|
|
249
256
|
/**
|
|
250
257
|
* The hash a server uses for `client_unique_identifier`, keyed by the server's
|
|
251
258
|
* major version.
|
|
@@ -254,16 +261,20 @@ function generateKey(): KeyObject {
|
|
|
254
261
|
* 6 switched to SHA-256 (32 bytes). The algorithm is not negotiable on the
|
|
255
262
|
* wire, so a client that guesses wrong computes a UID that matches nothing the
|
|
256
263
|
* server ever sends — self-identification by UID silently fails.
|
|
257
|
-
*
|
|
258
|
-
* May gain members in a minor release, should a server generation change the
|
|
259
|
-
* hash again: a `switch` over it needs a default case.
|
|
260
264
|
*/
|
|
261
|
-
export
|
|
265
|
+
export const UIDAlgorithm: typeof uidAlgorithmValues =
|
|
266
|
+
/* @__PURE__ */ Object.freeze(uidAlgorithmValues);
|
|
267
|
+
/**
|
|
268
|
+
* One of {@link UIDAlgorithm}. May gain members in a minor release, should a
|
|
269
|
+
* server generation change the hash again: a `switch` over it needs a default
|
|
270
|
+
* case.
|
|
271
|
+
*/
|
|
272
|
+
export type UIDAlgorithm = (typeof UIDAlgorithm)[keyof typeof UIDAlgorithm];
|
|
262
273
|
|
|
263
274
|
/** Pick the UID hash from a `virtualserver_version` string, e.g. "6.0.0-beta13.1". */
|
|
264
275
|
export function uidAlgorithmForVersion(version: string): UIDAlgorithm {
|
|
265
276
|
const major = Number.parseInt(version, 10);
|
|
266
|
-
return Number.isNaN(major) || major < 6 ?
|
|
277
|
+
return Number.isNaN(major) || major < 6 ? UIDAlgorithm.SHA1 : UIDAlgorithm.SHA256;
|
|
267
278
|
}
|
|
268
279
|
|
|
269
280
|
/**
|
|
@@ -9,7 +9,7 @@ export {
|
|
|
9
9
|
hash512,
|
|
10
10
|
importPublicKey,
|
|
11
11
|
} from "./identity.js";
|
|
12
|
-
export
|
|
12
|
+
export { UIDAlgorithm } from "./identity.js";
|
|
13
13
|
export { Crypt } from "./crypt.js";
|
|
14
14
|
export type { KeyNonce } from "./crypt.js";
|
|
15
15
|
export { EAX, aesCmac } from "./eax.js";
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { resolveSrv } from "node:dns/promises";
|
|
2
2
|
import { createConnection } from "node:net";
|
|
3
|
-
import
|
|
3
|
+
import { ResolveSource } from "../types.js";
|
|
4
|
+
import type { AddrResolver, ResolvedAddr } from "../types.js";
|
|
4
5
|
import { isIpAddress, joinHostPort, splitHostPort } from "../address.js";
|
|
5
6
|
import { abortable } from "../abort.js";
|
|
7
|
+
import { TeamspeakError } from "@honeybbq/teamspeak";
|
|
6
8
|
import { InvalidAddressError } from "../errors.js";
|
|
7
9
|
|
|
8
10
|
const TS_DNS_DEFAULT_PORT = 41144;
|
|
11
|
+
/** What a TSDNS entry has for a port to say: the one the user gave. */
|
|
12
|
+
const PORT_PLACEHOLDER = ":$PORT";
|
|
9
13
|
const NICKNAME_LOOKUP_URL = "https://named.myteamspeak.com/lookup";
|
|
10
14
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
11
15
|
/**
|
|
@@ -29,14 +33,31 @@ export const MAX_DISCOVERY_RESPONSE: number = 8 * 1024;
|
|
|
29
33
|
export interface ResolverOptions {
|
|
30
34
|
/** Look dot-less names up at named.myteamspeak.com; off by default. */
|
|
31
35
|
readonly nicknameLookup?: boolean | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The nickname lookup endpoint, an http: or https: URL; the default is
|
|
38
|
+
* `https://named.myteamspeak.com/lookup`. Used only with `nicknameLookup`:
|
|
39
|
+
* setting it does not turn the lookup on. For a test double or a mirror of
|
|
40
|
+
* the service. The name is sent as the `name` query parameter, and the first
|
|
41
|
+
* line of the answer is the address. A URL that is not http: or https: throws
|
|
42
|
+
* a TypeError.
|
|
43
|
+
*/
|
|
44
|
+
readonly nicknameService?: string | URL | undefined;
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The resolver a Client uses unless it is given another. It resolves as the
|
|
49
|
+
* TeamSpeak application does, so on its own it shows where an address leads:
|
|
50
|
+
* `await new Resolver().resolve("ts.example.com")`. Answers are kept for ten
|
|
51
|
+
* minutes.
|
|
52
|
+
*/
|
|
53
|
+
export class Resolver implements AddrResolver {
|
|
35
54
|
readonly #cache = new Map<string, ResolvedAddr[]>();
|
|
36
55
|
readonly #nicknameLookup: boolean;
|
|
56
|
+
readonly #nicknameService: URL;
|
|
37
57
|
|
|
38
58
|
constructor(options: ResolverOptions = {}) {
|
|
39
59
|
this.#nicknameLookup = options.nicknameLookup ?? false;
|
|
60
|
+
this.#nicknameService = parseNicknameService(options.nicknameService ?? NICKNAME_LOOKUP_URL);
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
/**
|
|
@@ -58,7 +79,7 @@ export class Resolver {
|
|
|
58
79
|
options.signal?.throwIfAborted();
|
|
59
80
|
if (!budget.aborted) throw error;
|
|
60
81
|
const { host, port } = splitHostPort(inputAddr);
|
|
61
|
-
return [{ addr: joinHostPort(host, port), source:
|
|
82
|
+
return [{ addr: joinHostPort(host, port), source: ResolveSource.Direct }];
|
|
62
83
|
}
|
|
63
84
|
}
|
|
64
85
|
|
|
@@ -73,14 +94,14 @@ export class Resolver {
|
|
|
73
94
|
const { host, port } = splitHostPort(inputAddr);
|
|
74
95
|
|
|
75
96
|
if (isIpAddress(host)) {
|
|
76
|
-
return [{ addr: joinHostPort(host, port), source:
|
|
97
|
+
return [{ addr: joinHostPort(host, port), source: ResolveSource.Direct }];
|
|
77
98
|
}
|
|
78
99
|
|
|
79
100
|
// A dot-less name may be a server nickname, which only the TeamSpeak
|
|
80
101
|
// service can answer: asking it sends the name to a third party, so it is
|
|
81
102
|
// opt-in. Otherwise the name is resolved like any host.
|
|
82
103
|
if (nicknames && this.#nicknameLookup && !host.includes(".") && host !== "localhost") {
|
|
83
|
-
const nickAddr = await resolveNickname(host, signal);
|
|
104
|
+
const nickAddr = await resolveNickname(host, this.#nicknameService, signal);
|
|
84
105
|
signal.throwIfAborted();
|
|
85
106
|
if (nickAddr) return this.#resolve(nickAddr, signal, false);
|
|
86
107
|
}
|
|
@@ -92,20 +113,28 @@ export class Resolver {
|
|
|
92
113
|
const domainList = getDomainList(host);
|
|
93
114
|
|
|
94
115
|
// TSDNS via SRV _tsdns._tcp.<domain>
|
|
95
|
-
const tsdnsSrv = await this.#resolveTSDNSSRV(domainList, host, signal);
|
|
96
|
-
if (tsdnsSrv) {
|
|
97
|
-
return this.#setCache(
|
|
116
|
+
const tsdnsSrv = await this.#resolveTSDNSSRV(domainList, host, port, signal);
|
|
117
|
+
if (tsdnsSrv.length > 0) {
|
|
118
|
+
return this.#setCache(
|
|
119
|
+
inputAddr,
|
|
120
|
+
tsdnsSrv.map((addr) => ({ addr, source: ResolveSource.TSDNSSRV })),
|
|
121
|
+
);
|
|
98
122
|
}
|
|
99
123
|
|
|
100
124
|
// TSDNS direct :41144
|
|
101
|
-
const tsdnsDirect = await this.#resolveTSDNSDirect(domainList, host, signal);
|
|
102
|
-
if (tsdnsDirect) {
|
|
103
|
-
return this.#setCache(
|
|
125
|
+
const tsdnsDirect = await this.#resolveTSDNSDirect(domainList, host, port, signal);
|
|
126
|
+
if (tsdnsDirect.length > 0) {
|
|
127
|
+
return this.#setCache(
|
|
128
|
+
inputAddr,
|
|
129
|
+
tsdnsDirect.map((addr) => ({ addr, source: ResolveSource.TSDNSDirect })),
|
|
130
|
+
);
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
signal.throwIfAborted();
|
|
107
134
|
// Plain DNS fallback
|
|
108
|
-
const fallback: ResolvedAddr[] = [
|
|
135
|
+
const fallback: ResolvedAddr[] = [
|
|
136
|
+
{ addr: joinHostPort(host, port), source: ResolveSource.Direct },
|
|
137
|
+
];
|
|
109
138
|
return this.#setCache(inputAddr, fallback);
|
|
110
139
|
}
|
|
111
140
|
|
|
@@ -130,7 +159,7 @@ export class Resolver {
|
|
|
130
159
|
if (!srvs || srvs.length === 0) return null;
|
|
131
160
|
return orderSrvRecords(srvs).map((srv) => ({
|
|
132
161
|
addr: joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)),
|
|
133
|
-
source:
|
|
162
|
+
source: ResolveSource.SRV,
|
|
134
163
|
}));
|
|
135
164
|
} catch {
|
|
136
165
|
return null;
|
|
@@ -140,40 +169,77 @@ export class Resolver {
|
|
|
140
169
|
async #resolveTSDNSSRV(
|
|
141
170
|
domains: string[],
|
|
142
171
|
queryHost: string,
|
|
172
|
+
port: string,
|
|
143
173
|
signal?: AbortSignal,
|
|
144
|
-
): Promise<string
|
|
174
|
+
): Promise<string[]> {
|
|
145
175
|
for (const domain of domains) {
|
|
146
176
|
try {
|
|
147
177
|
const srvs = await abortable(resolveSrv(`_tsdns._tcp.${domain}`), signal);
|
|
148
178
|
if (!srvs || srvs.length === 0) continue;
|
|
149
179
|
for (const srv of orderSrvRecords(srvs)) {
|
|
150
180
|
const tsdnsAddr = joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port));
|
|
151
|
-
const result = await
|
|
152
|
-
if (result) return result;
|
|
181
|
+
const result = await askTSDNS(tsdnsAddr, queryHost, port, signal);
|
|
182
|
+
if (result.length > 0) return result;
|
|
153
183
|
}
|
|
154
184
|
} catch {
|
|
155
185
|
continue;
|
|
156
186
|
}
|
|
157
187
|
}
|
|
158
|
-
return
|
|
188
|
+
return [];
|
|
159
189
|
}
|
|
160
190
|
|
|
161
191
|
async #resolveTSDNSDirect(
|
|
162
192
|
domains: string[],
|
|
163
193
|
queryHost: string,
|
|
194
|
+
port: string,
|
|
164
195
|
signal?: AbortSignal,
|
|
165
|
-
): Promise<string
|
|
196
|
+
): Promise<string[]> {
|
|
166
197
|
// Asked at once rather than in turn, so the probes cost one timeout in
|
|
167
198
|
// all; the most specific domain's answer wins.
|
|
168
199
|
const answers = await Promise.all(
|
|
169
200
|
domains.map((domain) =>
|
|
170
|
-
|
|
201
|
+
askTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, port, signal),
|
|
171
202
|
),
|
|
172
203
|
);
|
|
173
|
-
return answers.find((answer) => answer
|
|
204
|
+
return answers.find((answer) => answer.length > 0) ?? [];
|
|
174
205
|
}
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Ask one TSDNS server; a server that fails to answer is one with no answer,
|
|
210
|
+
* so it never fails the resolution.
|
|
211
|
+
*/
|
|
212
|
+
async function askTSDNS(
|
|
213
|
+
server: string,
|
|
214
|
+
host: string,
|
|
215
|
+
port: string,
|
|
216
|
+
signal?: AbortSignal,
|
|
217
|
+
): Promise<string[]> {
|
|
218
|
+
try {
|
|
219
|
+
return normalizeTSDNSEntries(await lookupTSDNS(server, host, { signal }), port);
|
|
220
|
+
} catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Make each entry of a TSDNS answer a `host:port`, as TeamSpeak reads it: an
|
|
227
|
+
* entry without a port means 9987, and the port `$PORT` means `port`, the one
|
|
228
|
+
* the address being resolved had.
|
|
229
|
+
*
|
|
230
|
+
* @internal
|
|
231
|
+
*/
|
|
232
|
+
export function normalizeTSDNSEntries(entries: readonly string[], port: string): string[] {
|
|
233
|
+
return entries.map((entry) => {
|
|
234
|
+
if (entry.endsWith(PORT_PLACEHOLDER)) {
|
|
235
|
+
const host = splitHostPort(entry.slice(0, -PORT_PLACEHOLDER.length)).host;
|
|
236
|
+
return joinHostPort(host, port);
|
|
237
|
+
}
|
|
238
|
+
const target = splitHostPort(entry, "9987");
|
|
239
|
+
return joinHostPort(target.host, target.port);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
177
243
|
// ---- Helpers ----------------------------------------------------------------
|
|
178
244
|
|
|
179
245
|
/** The part of an SRV record that decides the order they are tried in. */
|
|
@@ -228,13 +294,27 @@ export function orderSrvRecords<T extends SrvRecord>(
|
|
|
228
294
|
return ordered;
|
|
229
295
|
}
|
|
230
296
|
|
|
297
|
+
function parseNicknameService(service: string | URL): URL {
|
|
298
|
+
let url: URL;
|
|
299
|
+
try {
|
|
300
|
+
url = new URL(service);
|
|
301
|
+
} catch {
|
|
302
|
+
throw new TypeError(`nicknameService is not a URL: ${String(service)}`);
|
|
303
|
+
}
|
|
304
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
305
|
+
throw new TypeError(`nicknameService is an http: or https: URL, not ${url.protocol}`);
|
|
306
|
+
}
|
|
307
|
+
return url;
|
|
308
|
+
}
|
|
309
|
+
|
|
231
310
|
/** @internal Ask the nickname service for `nickname`'s address. */
|
|
232
311
|
export async function resolveNickname(
|
|
233
312
|
nickname: string,
|
|
313
|
+
service: URL,
|
|
234
314
|
signal?: AbortSignal,
|
|
235
315
|
): Promise<string | null> {
|
|
236
316
|
try {
|
|
237
|
-
const url = new URL(
|
|
317
|
+
const url = new URL(service);
|
|
238
318
|
url.searchParams.set("name", nickname);
|
|
239
319
|
const init: RequestInit = signal ? { signal } : {};
|
|
240
320
|
const resp = await fetch(url.toString(), init);
|
|
@@ -267,54 +347,82 @@ async function readLimited(resp: Response, limit: number): Promise<string> {
|
|
|
267
347
|
return Buffer.concat(chunks).toString("utf8");
|
|
268
348
|
}
|
|
269
349
|
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Ask a TSDNS server where `host` is. The answer is the server's targets in
|
|
352
|
+
* its order and as it sent them: an entry may have no port, which TeamSpeak
|
|
353
|
+
* takes as 9987, or the literal port `$PORT`, the port the user gave (9987 by
|
|
354
|
+
* default). {@link Resolver} applies both. A name the server has no record for
|
|
355
|
+
* gives an empty list.
|
|
356
|
+
*
|
|
357
|
+
* The answer ends at its first line break or when the server closes the
|
|
358
|
+
* connection, whichever comes first. It rejects when the connection fails,
|
|
359
|
+
* when no answer completes within three seconds or grows past 8 KiB, and with
|
|
360
|
+
* the signal's reason when it aborts.
|
|
361
|
+
*
|
|
362
|
+
* @param server - The TSDNS server, `host` or `host:port`; the port defaults to 41144.
|
|
363
|
+
* @param host - The name to look up; it is sent without a port.
|
|
364
|
+
*/
|
|
365
|
+
export function lookupTSDNS(
|
|
366
|
+
server: string,
|
|
367
|
+
host: string,
|
|
368
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
369
|
+
): Promise<string[]> {
|
|
370
|
+
const { signal } = options;
|
|
371
|
+
const target = splitHostPort(server, String(TS_DNS_DEFAULT_PORT));
|
|
372
|
+
const port = parseInt(target.port, 10);
|
|
373
|
+
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
376
|
+
const chunks: Buffer[] = [];
|
|
377
|
+
let size = 0;
|
|
378
|
+
let done = false;
|
|
379
|
+
const finish = (error: unknown, answer?: string[]): void => {
|
|
380
|
+
if (done) return;
|
|
381
|
+
done = true;
|
|
282
382
|
clearTimeout(timeout);
|
|
283
383
|
signal?.removeEventListener("abort", onAbort);
|
|
284
384
|
socket.destroy();
|
|
285
|
-
|
|
385
|
+
if (error !== undefined) reject(error);
|
|
386
|
+
else resolve(answer ?? []);
|
|
286
387
|
};
|
|
287
|
-
const
|
|
288
|
-
|
|
388
|
+
const answerOf = (bytes: Buffer): string[] => {
|
|
389
|
+
const line = bytes.toString("utf8").trim();
|
|
390
|
+
return !line || line === "404" || line === "errors" ? [] : line.split(/\s+/);
|
|
391
|
+
};
|
|
392
|
+
const timedOut = (): void =>
|
|
393
|
+
finish(new TeamspeakError(`the TSDNS server ${server} did not answer in time`));
|
|
394
|
+
const onAbort = (): void => finish(signal?.reason);
|
|
395
|
+
const timeout = setTimeout(timedOut, TSDNS_TIMEOUT_MS);
|
|
289
396
|
|
|
290
|
-
const socket = createConnection({ host, port, timeout: 2_000 }, () => {
|
|
291
|
-
socket.write(`${
|
|
397
|
+
const socket = createConnection({ host: target.host, port, timeout: 2_000 }, () => {
|
|
398
|
+
socket.write(`${host}\n`);
|
|
292
399
|
});
|
|
293
400
|
|
|
294
|
-
let buf = "";
|
|
295
401
|
socket.on("data", (chunk: Buffer) => {
|
|
296
|
-
|
|
297
|
-
|
|
402
|
+
chunks.push(chunk);
|
|
403
|
+
size += chunk.length;
|
|
404
|
+
const all = chunks.length === 1 ? chunks[0]! : Buffer.concat(chunks);
|
|
405
|
+
chunks.length = 0;
|
|
406
|
+
chunks.push(all);
|
|
407
|
+
const idx = all.indexOf(0x0a);
|
|
298
408
|
if (idx >= 0 && idx < MAX_DISCOVERY_RESPONSE) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
} else if (buf.length >= MAX_DISCOVERY_RESPONSE) {
|
|
409
|
+
finish(undefined, answerOf(all.subarray(0, idx)));
|
|
410
|
+
} else if (size >= MAX_DISCOVERY_RESPONSE) {
|
|
302
411
|
// No answer is that long.
|
|
303
|
-
finish(
|
|
412
|
+
finish(
|
|
413
|
+
new TeamspeakError(
|
|
414
|
+
`the TSDNS server ${server} sent ${MAX_DISCOVERY_RESPONSE} bytes without ending its answer`,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
304
417
|
}
|
|
305
418
|
});
|
|
306
|
-
|
|
307
|
-
socket.on("error", () => finish(
|
|
308
|
-
socket.on("timeout",
|
|
419
|
+
socket.on("end", () => finish(undefined, answerOf(Buffer.concat(chunks))));
|
|
420
|
+
socket.on("error", (error) => finish(error));
|
|
421
|
+
socket.on("timeout", timedOut);
|
|
309
422
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
310
423
|
});
|
|
311
424
|
}
|
|
312
425
|
|
|
313
|
-
function splitHostPortParts(addr: string): [string, string] {
|
|
314
|
-
const { host, port } = splitHostPort(addr);
|
|
315
|
-
return [host, port];
|
|
316
|
-
}
|
|
317
|
-
|
|
318
426
|
function getDomainList(host: string): string[] {
|
|
319
427
|
const parts = host.split(".");
|
|
320
428
|
const list: string[] = [];
|
|
@@ -78,6 +78,7 @@ function parseLicenseBlock(data: Uint8Array): { block: LicenseBlock; consumed: n
|
|
|
78
78
|
if (data.length < MIN_BLOCK_LEN) throw new Error("license too short");
|
|
79
79
|
if (data[0] !== 0) throw new Error(`wrong key kind in license: ${data[0]}`);
|
|
80
80
|
|
|
81
|
+
// oxlint-disable-next-line typescript/no-unsafe-enum-assignment -- read off the wire; parseBlockPayload refuses a value no member names
|
|
81
82
|
const blockType = data[33] as LicenseBlockType;
|
|
82
83
|
|
|
83
84
|
// Timestamps are seconds since Unix epoch offset by 0x50e22700
|
package/src/client/index.ts
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
// The client
|
|
12
12
|
export { Client } from "./client.js";
|
|
13
|
-
export { EventName, GroupWhisperType, GroupWhisperScope } from "./types.js";
|
|
13
|
+
export { EventName, GroupWhisperType, GroupWhisperScope, ResolveSource } from "./types.js";
|
|
14
14
|
|
|
15
|
-
export
|
|
15
|
+
export { Resolver, lookupTSDNS } from "./discovery/resolver.js";
|
|
16
|
+
export type { ResolverOptions } from "./discovery/resolver.js";
|
|
17
|
+
|
|
18
|
+
export { ViewStatus } from "./view.js";
|
|
19
|
+
export type { ServerView, ViewChangedEvent, ViewReadyEvent } from "./view.js";
|
|
16
20
|
|
|
17
21
|
export type {
|
|
18
22
|
EventMap,
|
|
@@ -47,11 +51,15 @@ export * from "./api/avatars.js";
|
|
|
47
51
|
export * from "./api/files.js";
|
|
48
52
|
|
|
49
53
|
// File transfer data
|
|
50
|
-
export {
|
|
54
|
+
export {
|
|
55
|
+
dialFileTransfer,
|
|
56
|
+
uploadFileData,
|
|
57
|
+
downloadFileData,
|
|
58
|
+
FileTransferStage,
|
|
59
|
+
} from "./transfer.js";
|
|
51
60
|
export type {
|
|
52
61
|
FileTransferOptions,
|
|
53
62
|
FileTransferTrace,
|
|
54
|
-
FileTransferStage,
|
|
55
63
|
FileTransferDialStartInfo,
|
|
56
64
|
FileTransferDialDoneInfo,
|
|
57
65
|
FileTransferProgressInfo,
|
|
@@ -99,4 +107,4 @@ export {
|
|
|
99
107
|
uidAlgorithmForVersion,
|
|
100
108
|
MAX_SECURITY_LEVEL,
|
|
101
109
|
} from "./crypto/index.js";
|
|
102
|
-
export
|
|
110
|
+
export { UIDAlgorithm } from "./crypto/index.js";
|
package/src/client/trace.ts
CHANGED
|
@@ -44,7 +44,10 @@ export interface ResolveDoneInfo extends AttemptInfo {
|
|
|
44
44
|
readonly addr: string;
|
|
45
45
|
/** The `host:port` that will be dialled. */
|
|
46
46
|
readonly target: string;
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* What the resolver reports as the source of the answer dialled: one of
|
|
49
|
+
* `ResolveSource` for the built-in resolver. May be empty.
|
|
50
|
+
*/
|
|
48
51
|
readonly source: string;
|
|
49
52
|
/** Every answer the resolver gave, in order; empty when the address is used as given. */
|
|
50
53
|
readonly candidates: readonly ResolvedAddr[];
|
package/src/client/transfer.ts
CHANGED
|
@@ -44,8 +44,18 @@ export function failedToConnect(error: unknown): boolean {
|
|
|
44
44
|
return typeof error === "object" && error !== null && failedBeforeConnect.has(error);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** The values of the table below, before freezing. */
|
|
48
|
+
const fileTransferStageValues = {
|
|
49
|
+
/** Reaching the transfer port, before the key is written. */
|
|
50
|
+
Dial: "dial",
|
|
51
|
+
/** After the connection opened, while the key or the file moved. */
|
|
52
|
+
Transfer: "transfer",
|
|
53
|
+
} as const;
|
|
47
54
|
/** Where a failed transfer failed. */
|
|
48
|
-
export
|
|
55
|
+
export const FileTransferStage: typeof fileTransferStageValues =
|
|
56
|
+
/* @__PURE__ */ Object.freeze(fileTransferStageValues);
|
|
57
|
+
/** One of {@link FileTransferStage}. */
|
|
58
|
+
export type FileTransferStage = (typeof FileTransferStage)[keyof typeof FileTransferStage];
|
|
49
59
|
|
|
50
60
|
/** The file transfer port is about to be dialled. */
|
|
51
61
|
export interface FileTransferDialStartInfo {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
PacketType,
|
|
11
11
|
PacketFlags,
|
|
12
12
|
packetType,
|
|
13
|
+
packetTypeOfByte,
|
|
13
14
|
packetFlags,
|
|
14
15
|
buildC2SHeader,
|
|
15
16
|
parseS2CHeader,
|
|
@@ -563,7 +564,7 @@ export class PacketHandler {
|
|
|
563
564
|
if (!this.#conn) return;
|
|
564
565
|
if (data.length > TAG_SIZE + HEADER_SIZE - 1) {
|
|
565
566
|
this.#traffic.recordSent(
|
|
566
|
-
data[TAG_SIZE + HEADER_SIZE - 1]!
|
|
567
|
+
packetTypeOfByte(data[TAG_SIZE + HEADER_SIZE - 1]!),
|
|
567
568
|
data.length,
|
|
568
569
|
performance.now(),
|
|
569
570
|
);
|
|
@@ -597,7 +598,7 @@ export class PacketHandler {
|
|
|
597
598
|
const parsed = parseS2CHeader(header);
|
|
598
599
|
// Init1 belongs to the start of the handshake; once crypto init has
|
|
599
600
|
// completed, another one can only restart a session that is already set up.
|
|
600
|
-
const parsedType
|
|
601
|
+
const parsedType = packetTypeOfByte(parsed.typeFlagged);
|
|
601
602
|
if (parsedType === PacketType.Init1 && this.#crypt.cryptoInitComplete) return;
|
|
602
603
|
|
|
603
604
|
const p: Packet = {
|
|
@@ -27,8 +27,17 @@ export interface Packet {
|
|
|
27
27
|
receivedAt: number; // performance.now(), a monotonic clock
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The packet type in the low nibble of a type byte, as it came off the wire:
|
|
32
|
+
* the nibble can also hold 9 to 15, which name no type.
|
|
33
|
+
*/
|
|
34
|
+
export function packetTypeOfByte(typeByte: number): PacketType {
|
|
35
|
+
// oxlint-disable-next-line typescript/no-unsafe-enum-assignment -- read off the wire, where the nibble can be any value
|
|
36
|
+
return typeByte & 0x0f;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export function packetType(p: Packet): PacketType {
|
|
31
|
-
return p.typeFlagged
|
|
40
|
+
return packetTypeOfByte(p.typeFlagged);
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
export function packetFlags(p: Packet): number {
|
package/src/client/types.ts
CHANGED
|
@@ -385,6 +385,32 @@ export type EventMiddleware = (next: (event: ServerEvent) => void) => (event: Se
|
|
|
385
385
|
|
|
386
386
|
// ---- Resolved address (from discovery) --------------------------------------
|
|
387
387
|
|
|
388
|
+
/** The values of the table below, before freezing. */
|
|
389
|
+
const resolveSourceValues = {
|
|
390
|
+
/** A `_ts3._udp` SRV record of the host named the address. */
|
|
391
|
+
SRV: "SRV",
|
|
392
|
+
/**
|
|
393
|
+
* A TSDNS server, found through a `_tsdns._tcp` SRV record of the host's
|
|
394
|
+
* domain, named the address.
|
|
395
|
+
*/
|
|
396
|
+
TSDNSSRV: "TSDNS-SRV",
|
|
397
|
+
/** A TSDNS server on the host's domain, at its default port, named the address. */
|
|
398
|
+
TSDNSDirect: "TSDNS-Direct",
|
|
399
|
+
/**
|
|
400
|
+
* The address as given: an IP address, a name nothing else answered for, or
|
|
401
|
+
* any name once resolution ran out of time.
|
|
402
|
+
*/
|
|
403
|
+
Direct: "Direct",
|
|
404
|
+
} as const;
|
|
405
|
+
/** How the built-in resolver found an address, as `ResolvedAddr` records it. */
|
|
406
|
+
export const ResolveSource: typeof resolveSourceValues =
|
|
407
|
+
/* @__PURE__ */ Object.freeze(resolveSourceValues);
|
|
408
|
+
/**
|
|
409
|
+
* One of {@link ResolveSource}. May gain members in a minor release: a
|
|
410
|
+
* `switch` over it needs a default case.
|
|
411
|
+
*/
|
|
412
|
+
export type ResolveSource = (typeof ResolveSource)[keyof typeof ResolveSource];
|
|
413
|
+
|
|
388
414
|
/**
|
|
389
415
|
* An address a server's name resolved to. Only `addr` is needed to connect;
|
|
390
416
|
* the rest describes the answer.
|
|
@@ -392,7 +418,10 @@ export type EventMiddleware = (next: (event: ServerEvent) => void) => (event: Se
|
|
|
392
418
|
export interface ResolvedAddr {
|
|
393
419
|
/** The endpoint, as `host:port`. */
|
|
394
420
|
readonly addr: string;
|
|
395
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* How it was found. The built-in resolver sets one of `ResolveSource`; a
|
|
423
|
+
* plain string, since an `AddrResolver` of your own may set any.
|
|
424
|
+
*/
|
|
396
425
|
readonly source?: string | undefined;
|
|
397
426
|
/** When the answer stops being valid; unset for no stated lifetime. */
|
|
398
427
|
readonly expiry?: Date | undefined;
|