@honeybbq/teamspeak 1.0.0-beta.12 → 1.0.0-beta.13

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.
Files changed (139) hide show
  1. package/README.md +40 -5
  2. package/dist/client/capabilities.d.ts +4 -4
  3. package/dist/client/capabilities.d.ts.map +1 -1
  4. package/dist/client/capabilities.js +3 -8
  5. package/dist/client/capabilities.js.map +1 -1
  6. package/dist/client/client.d.ts.map +1 -1
  7. package/dist/client/client.js +50 -9
  8. package/dist/client/client.js.map +1 -1
  9. package/dist/client/crypto/identity.d.ts +2 -0
  10. package/dist/client/crypto/identity.d.ts.map +1 -1
  11. package/dist/client/crypto/identity.js +2 -2
  12. package/dist/client/crypto/identity.js.map +1 -1
  13. package/dist/client/crypto/index.d.ts +1 -1
  14. package/dist/client/crypto/index.d.ts.map +1 -1
  15. package/dist/client/crypto/index.js +1 -1
  16. package/dist/client/crypto/index.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/index.d.ts.map +1 -1
  19. package/dist/client/index.js +1 -1
  20. package/dist/client/index.js.map +1 -1
  21. package/dist/client/trace.d.ts +7 -0
  22. package/dist/client/trace.d.ts.map +1 -1
  23. package/dist/client/transport/handler.d.ts +2 -0
  24. package/dist/client/transport/handler.d.ts.map +1 -1
  25. package/dist/client/transport/handler.js +4 -0
  26. package/dist/client/transport/handler.js.map +1 -1
  27. package/dist/client/types.d.ts +9 -2
  28. package/dist/client/types.d.ts.map +1 -1
  29. package/dist/client/types.js +2 -0
  30. package/dist/client/types.js.map +1 -1
  31. package/dist/client/view.d.ts +3 -3
  32. package/dist/client/view.d.ts.map +1 -1
  33. package/dist/client/view.js.map +1 -1
  34. package/dist/core/api/channels.d.ts +7 -3
  35. package/dist/core/api/channels.d.ts.map +1 -1
  36. package/dist/core/api/channels.js +5 -3
  37. package/dist/core/api/channels.js.map +1 -1
  38. package/dist/core/api/clients.d.ts +38 -1
  39. package/dist/core/api/clients.d.ts.map +1 -1
  40. package/dist/core/api/clients.js +33 -2
  41. package/dist/core/api/clients.js.map +1 -1
  42. package/dist/core/api/command.d.ts +18 -1
  43. package/dist/core/api/command.d.ts.map +1 -1
  44. package/dist/core/api/command.js +7 -1
  45. package/dist/core/api/command.js.map +1 -1
  46. package/dist/core/api/instance.d.ts +10 -2
  47. package/dist/core/api/instance.d.ts.map +1 -1
  48. package/dist/core/api/instance.js +6 -2
  49. package/dist/core/api/instance.js.map +1 -1
  50. package/dist/core/api/moderation.d.ts +7 -0
  51. package/dist/core/api/moderation.d.ts.map +1 -1
  52. package/dist/core/api/moderation.js +7 -0
  53. package/dist/core/api/moderation.js.map +1 -1
  54. package/dist/core/api/parse.d.ts.map +1 -1
  55. package/dist/core/api/parse.js +1 -0
  56. package/dist/core/api/parse.js.map +1 -1
  57. package/dist/core/api/server.d.ts +9 -0
  58. package/dist/core/api/server.d.ts.map +1 -1
  59. package/dist/core/api/server.js +3 -0
  60. package/dist/core/api/server.js.map +1 -1
  61. package/dist/core/command/responses.d.ts.map +1 -1
  62. package/dist/core/command/responses.js +1 -0
  63. package/dist/core/command/responses.js.map +1 -1
  64. package/dist/core/events.d.ts +47 -5
  65. package/dist/core/events.d.ts.map +1 -1
  66. package/dist/core/index.d.ts +2 -2
  67. package/dist/core/index.d.ts.map +1 -1
  68. package/dist/core/index.js +1 -1
  69. package/dist/core/index.js.map +1 -1
  70. package/dist/core/notification-name.d.ts +4 -0
  71. package/dist/core/notification-name.d.ts.map +1 -1
  72. package/dist/core/notification-name.js +4 -0
  73. package/dist/core/notification-name.js.map +1 -1
  74. package/dist/core/notifications.d.ts +6 -2
  75. package/dist/core/notifications.d.ts.map +1 -1
  76. package/dist/core/notifications.js +32 -1
  77. package/dist/core/notifications.js.map +1 -1
  78. package/dist/core/types.d.ts +7 -0
  79. package/dist/core/types.d.ts.map +1 -1
  80. package/dist/core/types.js +16 -0
  81. package/dist/core/types.js.map +1 -1
  82. package/dist/query/client.d.ts +12 -3
  83. package/dist/query/client.d.ts.map +1 -1
  84. package/dist/query/client.js +22 -15
  85. package/dist/query/client.js.map +1 -1
  86. package/dist/query/index.d.ts +1 -1
  87. package/dist/query/index.d.ts.map +1 -1
  88. package/dist/query/options.d.ts +17 -5
  89. package/dist/query/options.d.ts.map +1 -1
  90. package/dist/query/options.js +1 -0
  91. package/dist/query/options.js.map +1 -1
  92. package/dist/query/ssh.d.ts.map +1 -1
  93. package/dist/query/ssh.js +4 -1
  94. package/dist/query/ssh.js.map +1 -1
  95. package/dist/query/stream.d.ts +8 -3
  96. package/dist/query/stream.d.ts.map +1 -1
  97. package/dist/query/stream.js +26 -3
  98. package/dist/query/stream.js.map +1 -1
  99. package/dist/query/tcp.js +1 -1
  100. package/dist/query/tcp.js.map +1 -1
  101. package/dist/query/transport.d.ts +35 -0
  102. package/dist/query/transport.d.ts.map +1 -1
  103. package/dist/query/web.d.ts +17 -5
  104. package/dist/query/web.d.ts.map +1 -1
  105. package/dist/query/web.js +38 -9
  106. package/dist/query/web.js.map +1 -1
  107. package/package.json +6 -5
  108. package/src/client/capabilities.ts +14 -11
  109. package/src/client/client.ts +47 -6
  110. package/src/client/crypto/identity.ts +2 -2
  111. package/src/client/crypto/index.ts +1 -0
  112. package/src/client/index.ts +2 -0
  113. package/src/client/trace.ts +7 -0
  114. package/src/client/transport/handler.ts +5 -0
  115. package/src/client/types.ts +9 -0
  116. package/src/client/view.ts +2 -3
  117. package/src/core/api/channels.ts +9 -3
  118. package/src/core/api/clients.ts +73 -2
  119. package/src/core/api/command.ts +22 -1
  120. package/src/core/api/instance.ts +20 -2
  121. package/src/core/api/moderation.ts +7 -0
  122. package/src/core/api/parse.ts +1 -0
  123. package/src/core/api/server.ts +12 -0
  124. package/src/core/command/responses.ts +1 -0
  125. package/src/core/events.ts +48 -5
  126. package/src/core/index.ts +2 -1
  127. package/src/core/notification-name.ts +4 -0
  128. package/src/core/notifications.ts +48 -17
  129. package/src/core/types.ts +14 -0
  130. package/src/query/client.ts +28 -15
  131. package/src/query/index.ts +1 -1
  132. package/src/query/options.ts +19 -5
  133. package/src/query/ssh.ts +6 -1
  134. package/src/query/stream.ts +31 -4
  135. package/src/query/tcp.ts +1 -1
  136. package/src/query/testing/live.ts +311 -0
  137. package/src/query/testing/ssh-keys.ts +67 -0
  138. package/src/query/transport.ts +36 -0
  139. package/src/query/web.ts +57 -14
@@ -0,0 +1,311 @@
1
+ /**
2
+ * What the live ServerQuery suites share: the environment the fixture writes,
3
+ * one connected client per transport, and a voice client to stand in as the
4
+ * other party. The suites that use it live in `*.integration.test.ts` files and
5
+ * run when `scripts/ts3-fixture.sh` has provisioned a server; see
6
+ * `src/query/integration.test.ts` for how to start one.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach } from "vitest";
13
+ import {
14
+ GroupType,
15
+ addPrivilegeKey,
16
+ deleteChannel,
17
+ listChannels,
18
+ listClients,
19
+ listServerGroups,
20
+ type ChannelID,
21
+ type ClientDBID,
22
+ type CommandExecutor,
23
+ } from "@honeybbq/teamspeak";
24
+ import { Client } from "../../client/client.js";
25
+ import { generateIdentitySync } from "../../client/crypto/identity.js";
26
+ import { QueryClient } from "../client.js";
27
+ import type { QueryClientOptions } from "../options.js";
28
+ import { connectSsh } from "../ssh.js";
29
+ import type { SshHostKey } from "../ssh.js";
30
+
31
+ export const VOICE_ADDR: string | undefined = process.env["TEAMSPEAK_ADDR"];
32
+ export const DEFAULT_CHANNEL: string = process.env["TEAMSPEAK_DEFAULT_CHANNEL"] ?? "";
33
+ export const SERVER_PASSWORD: string = process.env["TEAMSPEAK_SERVER_PASSWORD"] ?? "";
34
+ export const SERVER_FAMILY: string = process.env["TEAMSPEAK_QUERY_FAMILY"] ?? "";
35
+
36
+ const QUERY_KIND = process.env["TEAMSPEAK_QUERY_KIND"];
37
+ const QUERY_HOST = process.env["TEAMSPEAK_QUERY_HOST"] ?? "";
38
+ const QUERY_PORT = Number(process.env["TEAMSPEAK_QUERY_PORT"] ?? "10011");
39
+ const QUERY_URL = process.env["TEAMSPEAK_QUERY_URL"] ?? "";
40
+ const QUERY_API_KEY = process.env["TEAMSPEAK_QUERY_API_KEY"] ?? "";
41
+ export const LOGIN_NAME: string = process.env["TEAMSPEAK_QUERY_LOGIN_NAME"] ?? "";
42
+ export const LOGIN_PASSWORD: string = process.env["TEAMSPEAK_QUERY_LOGIN_PASSWORD"] ?? "";
43
+
44
+ const SSH_PORT = Number(process.env["TEAMSPEAK_QUERY_SSH_PORT"] ?? "0");
45
+ const SSH_HOST = process.env["TEAMSPEAK_QUERY_SSH_HOST"] ?? QUERY_HOST;
46
+ const SSH_USER = process.env["TEAMSPEAK_QUERY_SSH_USER"] ?? "";
47
+ const SSH_PASSWORD = process.env["TEAMSPEAK_QUERY_SSH_PASSWORD"] ?? "";
48
+ const SSH_KNOWN_HOSTS = process.env["TEAMSPEAK_QUERY_SSH_KNOWN_HOSTS"] ?? "";
49
+
50
+ export type Kind = "tcp" | "web" | "ssh";
51
+
52
+ /** The transports this run can use, from what the fixture provisioned. */
53
+ export const KINDS: readonly Kind[] = [
54
+ ...(QUERY_KIND === "tcp" || QUERY_HOST !== "" ? (["tcp"] as const) : []),
55
+ ...(QUERY_KIND === "web" || (QUERY_URL !== "" && QUERY_API_KEY !== "") ? (["web"] as const) : []),
56
+ ...(SSH_PORT > 0 ? (["ssh"] as const) : []),
57
+ ];
58
+
59
+ /** True when no server was provisioned: the suites skip rather than fail. */
60
+ export const SKIP: boolean = VOICE_ADDR === undefined || KINDS.length === 0;
61
+
62
+ /**
63
+ * Whether the run may stop and delete virtual servers and edit the instance:
64
+ * only against a server that is thrown away afterwards, such as the one a CI
65
+ * job starts.
66
+ */
67
+ export const DESTRUCTIVE: boolean = process.env["TEAMSPEAK_QUERY_DESTRUCTIVE"] === "1";
68
+
69
+ /** Whether the run may stop the server process, which ends every test after it. */
70
+ export const STOP_INSTANCE: boolean = process.env["TEAMSPEAK_QUERY_STOP_INSTANCE"] === "1";
71
+
72
+ export const CONNECT_TIMEOUT_MS: number = 15_000;
73
+ export const EVENT_TIMEOUT_MS: number = 10_000;
74
+ const CONNECT_ATTEMPTS = 3;
75
+ const RETRY_DELAY_MS = 2_000;
76
+
77
+ /**
78
+ * Time to let a TeamSpeak 3 server finish closing an SSH query session, as
79
+ * `src/query/integration.test.ts` explains.
80
+ */
81
+ const SSH_TEARDOWN_MS = 50;
82
+
83
+ /** Register the pause after each test that an SSH run needs. Call it once per file. */
84
+ export function pauseAfterSshTests(): void {
85
+ afterEach(async () => {
86
+ if (KINDS.includes("ssh")) await new Promise((resolve) => setTimeout(resolve, SSH_TEARDOWN_MS));
87
+ });
88
+ }
89
+
90
+ export const sleep = (ms: number): Promise<void> =>
91
+ new Promise((resolve) => setTimeout(resolve, ms));
92
+
93
+ /** Reject rather than hang when something expected never arrives. */
94
+ export function withTimeout<T>(
95
+ promise: Promise<T>,
96
+ timeoutMs: number = EVENT_TIMEOUT_MS,
97
+ ): Promise<T> {
98
+ return Promise.race([
99
+ promise,
100
+ new Promise<never>((_, reject) =>
101
+ setTimeout(() => reject(new Error("it did not arrive in time")), timeoutMs),
102
+ ),
103
+ ]);
104
+ }
105
+
106
+ /** A name no earlier run used, for what a test creates on the shared server. */
107
+ export function stamp(prefix: string): string {
108
+ return `${prefix}-${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
109
+ }
110
+
111
+ /** The options for one transport kind; `knownHosts` is the file SSH verifies against. */
112
+ export function optionsFor(
113
+ kind: Kind,
114
+ knownHosts: string,
115
+ nickname: string = "ts-js-live",
116
+ ): QueryClientOptions {
117
+ switch (kind) {
118
+ case "tcp":
119
+ return {
120
+ transport: {
121
+ kind: "tcp",
122
+ host: QUERY_HOST,
123
+ port: QUERY_PORT,
124
+ login: { name: LOGIN_NAME, password: LOGIN_PASSWORD },
125
+ },
126
+ server: { id: 1 },
127
+ nickname,
128
+ };
129
+ case "web":
130
+ return {
131
+ transport: { kind: "web", url: QUERY_URL, apiKey: QUERY_API_KEY },
132
+ server: { id: 1 },
133
+ };
134
+ case "ssh":
135
+ return {
136
+ transport: {
137
+ kind: "ssh",
138
+ host: SSH_HOST,
139
+ port: SSH_PORT,
140
+ username: SSH_USER,
141
+ password: SSH_PASSWORD,
142
+ knownHosts,
143
+ },
144
+ server: { id: 1 },
145
+ nickname,
146
+ };
147
+ default:
148
+ throw new TypeError(`unknown query transport ${String(kind)}`);
149
+ }
150
+ }
151
+
152
+ /** Connect, trying again when the query flood limit refuses an address that arrived too fast. */
153
+ export async function connectWithRetries(client: QueryClient): Promise<void> {
154
+ for (let attempt = 1; ; attempt++) {
155
+ try {
156
+ await client.connect({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
157
+ return;
158
+ } catch (error) {
159
+ if (attempt === CONNECT_ATTEMPTS) throw error;
160
+ console.warn(`query connect attempt ${attempt} of ${CONNECT_ATTEMPTS}: ${String(error)}`);
161
+ await sleep(RETRY_DELAY_MS);
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * One connected client per transport, and what the suite that made them has
168
+ * to tidy up.
169
+ */
170
+ export class LiveSession {
171
+ readonly clients: Map<Kind, QueryClient> = new Map<Kind, QueryClient>();
172
+ readonly #scratch = mkdtempSync(join(tmpdir(), "query-live-"));
173
+ #knownHosts = "";
174
+ readonly #voices: Client[] = [];
175
+
176
+ /** The file the SSH transport verifies against: the key the server presents, recorded first. */
177
+ get knownHosts(): string {
178
+ return this.#knownHosts;
179
+ }
180
+
181
+ /** Probe the SSH host key once, then connect one client per transport. */
182
+ async open(): Promise<void> {
183
+ if (KINDS.includes("ssh")) {
184
+ if (SSH_KNOWN_HOSTS !== "") {
185
+ this.#knownHosts = SSH_KNOWN_HOSTS;
186
+ } else {
187
+ const key = await probeSshKey();
188
+ this.#knownHosts = join(this.#scratch, "recorded");
189
+ writeFileSync(
190
+ this.#knownHosts,
191
+ `[${SSH_HOST}]:${SSH_PORT} ${key.keyType} ${Buffer.from(key.key).toString("base64")}\n`,
192
+ );
193
+ }
194
+ }
195
+ for (const kind of KINDS) {
196
+ const client = new QueryClient(optionsFor(kind, this.#knownHosts));
197
+ await connectWithRetries(client);
198
+ this.clients.set(kind, client);
199
+ }
200
+ }
201
+
202
+ /** A fresh client of one kind, which the caller disconnects. */
203
+ async connectNew(
204
+ kind: Kind,
205
+ nickname: string,
206
+ selectServer: boolean = true,
207
+ ): Promise<QueryClient> {
208
+ const options = optionsFor(kind, this.#knownHosts, nickname);
209
+ const client = new QueryClient(
210
+ selectServer || kind !== "web" ? options : { ...options, server: undefined },
211
+ );
212
+ await connectWithRetries(client);
213
+ return client;
214
+ }
215
+
216
+ /**
217
+ * A voice client on a throwaway identity, disconnected by {@link close}.
218
+ * With a `groupName` it joins holding that server group, through a privilege
219
+ * key the `admin` executor makes for it, so it may run admin commands too.
220
+ */
221
+ async voice(
222
+ nickname: string,
223
+ options: { admin?: CommandExecutor; groupName?: RegExp; channel?: string } = {},
224
+ ): Promise<Client> {
225
+ let privilegeKey = "";
226
+ if (options.admin !== undefined && options.groupName !== undefined) {
227
+ const group = (await listServerGroups(options.admin)).find(
228
+ (g) => g.type === GroupType.Regular && options.groupName!.test(g.name),
229
+ );
230
+ if (group === undefined)
231
+ throw new Error(`no server group matches ${String(options.groupName)}`);
232
+ privilegeKey = await addPrivilegeKey(options.admin, {
233
+ serverGroupID: group.id,
234
+ description: nickname,
235
+ });
236
+ }
237
+ const client = new Client(generateIdentitySync(8), VOICE_ADDR!, nickname, {
238
+ serverPassword: SERVER_PASSWORD,
239
+ defaultChannel: options.channel ?? DEFAULT_CHANNEL,
240
+ privilegeKey,
241
+ });
242
+ for (let attempt = 1; ; attempt++) {
243
+ try {
244
+ await client.connect({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
245
+ break;
246
+ } catch (error) {
247
+ if (attempt === CONNECT_ATTEMPTS) throw error;
248
+ await sleep(RETRY_DELAY_MS);
249
+ }
250
+ }
251
+ this.#voices.push(client);
252
+ // Antiflood counts connects, and a client that has only just arrived is
253
+ // not yet known to the others.
254
+ await sleep(1_100);
255
+ return client;
256
+ }
257
+
258
+ /** Remove channels a test made, whichever transport is at hand. */
259
+ async deleteChannels(ids: Iterable<ChannelID>): Promise<void> {
260
+ const admin = this.first();
261
+ for (const id of ids) await deleteChannel(admin, id, { force: true }).catch(() => {});
262
+ }
263
+
264
+ /** The channel with this name, or undefined. */
265
+ async channelNamed(name: string): Promise<ChannelID | undefined> {
266
+ return (await listChannels(this.first())).find((c) => c.name === name)?.id;
267
+ }
268
+
269
+ /**
270
+ * The database id of the client with this nickname. A voice client is not
271
+ * always on the list the moment its connect returns, so this looks for up
272
+ * to ten seconds.
273
+ */
274
+ async databaseIDOf(nickname: string): Promise<ClientDBID> {
275
+ const deadline = Date.now() + EVENT_TIMEOUT_MS;
276
+ for (;;) {
277
+ const found = (await listClients(this.first())).find((c) => c.nickname === nickname);
278
+ if (found?.databaseID !== undefined) return found.databaseID;
279
+ if (Date.now() > deadline) throw new Error(`no client named ${nickname} on the list`);
280
+ await sleep(250);
281
+ }
282
+ }
283
+
284
+ /** The first transport's client. */
285
+ first(): QueryClient {
286
+ return [...this.clients.values()][0]!;
287
+ }
288
+
289
+ async close(): Promise<void> {
290
+ for (const voice of this.#voices) await voice.disconnect().catch(() => {});
291
+ for (const client of this.clients.values()) await client.disconnect().catch(() => {});
292
+ rmSync(this.#scratch, { recursive: true, force: true });
293
+ }
294
+ }
295
+
296
+ /** Ask the SSH server for its key; the check runs before the login, so a refusing probe sends nothing. */
297
+ async function probeSshKey(): Promise<SshHostKey> {
298
+ let presented: SshHostKey | undefined;
299
+ await connectSsh({
300
+ host: SSH_HOST,
301
+ port: SSH_PORT,
302
+ username: SSH_USER,
303
+ password: SSH_PASSWORD,
304
+ hostKey: (key) => {
305
+ presented = key;
306
+ throw new Error("recorded, and not trusted yet");
307
+ },
308
+ }).catch(() => {});
309
+ if (presented === undefined) throw new Error(`the server at ${SSH_HOST}:${SSH_PORT} sent no key`);
310
+ return presented;
311
+ }
@@ -0,0 +1,67 @@
1
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
2
+
3
+ /** An Ed25519 key pair in the forms the SSH tests need. */
4
+ export interface TestSshKeyPair {
5
+ /** The unencrypted OpenSSH private key PEM, accepted by `ssh2`'s `Server` as a host key. */
6
+ readonly private: string;
7
+ /** The OpenSSH public key line, `ssh-ed25519 <base64>`, as in a known_hosts entry. */
8
+ readonly public: string;
9
+ }
10
+
11
+ function uint32(value: number): Buffer {
12
+ const out = Buffer.alloc(4);
13
+ out.writeUInt32BE(value);
14
+ return out;
15
+ }
16
+
17
+ function sshString(bytes: Buffer): Buffer {
18
+ return Buffer.concat([uint32(bytes.length), bytes]);
19
+ }
20
+
21
+ /**
22
+ * A fresh Ed25519 key pair for a test SSH server or a known_hosts entry.
23
+ *
24
+ * It is made with `node:crypto` and not with `ssh2`'s `utils.generateKeyPairSync`.
25
+ * That one strips every leading zero byte from the raw public key while it
26
+ * removes the BIT STRING's own padding byte, so a public key that really starts
27
+ * with 0x00 (one in 256) is written 31 bytes long and the private key it
28
+ * returns is rejected by `utils.parseKey` as "Malformed OpenSSH private key".
29
+ * Measured: 36 to 40 of 10,000 keys on Node 22, 24 and 26.
30
+ */
31
+ export function newSshKeyPair(): TestSshKeyPair {
32
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
33
+ const publicRaw = Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url");
34
+ const seed = Buffer.from(privateKey.export({ format: "jwk" }).d!, "base64url");
35
+ const name = sshString(Buffer.from("ssh-ed25519"));
36
+ const publicBlob = Buffer.concat([name, sshString(publicRaw)]);
37
+
38
+ // The openssh-key-v1 layout: no cipher, one key, the private part padded 1, 2, 3...
39
+ const check = randomBytes(4);
40
+ const inner = Buffer.concat([
41
+ check,
42
+ check,
43
+ publicBlob,
44
+ sshString(Buffer.concat([seed, publicRaw])),
45
+ sshString(Buffer.alloc(0)),
46
+ ]);
47
+ const padding = Buffer.from(
48
+ Array.from({ length: (8 - (inner.length % 8)) % 8 }, (_, i) => i + 1),
49
+ );
50
+ const body = Buffer.concat([
51
+ Buffer.from("openssh-key-v1\0"),
52
+ sshString(Buffer.from("none")),
53
+ sshString(Buffer.from("none")),
54
+ sshString(Buffer.alloc(0)),
55
+ uint32(1),
56
+ sshString(publicBlob),
57
+ sshString(Buffer.concat([inner, padding])),
58
+ ]);
59
+ const lines = body
60
+ .toString("base64")
61
+ .match(/.{1,70}/g)!
62
+ .join("\n");
63
+ return {
64
+ private: `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines}\n-----END OPENSSH PRIVATE KEY-----\n`,
65
+ public: `ssh-ed25519 ${publicBlob.toString("base64")}`,
66
+ };
67
+ }
@@ -1,3 +1,34 @@
1
+ /**
2
+ * What a connection reports about itself, enough for a metrics exporter to
3
+ * publish without reaching into the client.
4
+ *
5
+ * @experimental Part of the `@honeybbq/teamspeak/query` entry, which may
6
+ * change in a minor release.
7
+ */
8
+ export interface QueryTransportStats {
9
+ /** When the current connection was opened; undefined when there is none. */
10
+ readonly connectedAt: Date | undefined;
11
+ /** The kind of connection; undefined when there is none, or for a transport that does not say. */
12
+ readonly transport: "tcp" | "ssh" | "web" | undefined;
13
+ /**
14
+ * Where it points: `host:port` for raw TCP, `user@host:port` for SSH, the
15
+ * base URL for WebQuery. Never a password or an api key; "" when there is
16
+ * no connection.
17
+ */
18
+ readonly address: string;
19
+ /**
20
+ * What the line protocol has written, line terminators included; for
21
+ * WebQuery, the request bodies. It is what the transport sent, not what the
22
+ * socket did: headers and retries are not counted.
23
+ */
24
+ readonly bytesSent: number;
25
+ /**
26
+ * What the server has sent, banner included: the bytes read from the stream
27
+ * for raw TCP and SSH, the response bodies for WebQuery.
28
+ */
29
+ readonly bytesReceived: number;
30
+ }
31
+
1
32
  /**
2
33
  * The connection a ServerQuery client talks over.
3
34
  *
@@ -33,4 +64,9 @@ export interface QueryTransport {
33
64
  onClose: ((error: Error | undefined) => void) | null;
34
65
  /** End the connection. */
35
66
  close(): Promise<void>;
67
+ /**
68
+ * What the connection reports about itself, for `QueryClient.stats`. A
69
+ * transport that does not keep counts leaves it out.
70
+ */
71
+ stats?(): QueryTransportStats;
36
72
  }
package/src/query/web.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { request } from "node:http";
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
2
3
  import { TeamspeakError } from "@honeybbq/teamspeak";
3
4
  import { buildCommandOrdered, parseCommand, splitCommandRows } from "../core/internal.js";
4
- import type { QueryTransport } from "./transport.js";
5
+ import type { QueryTransport, QueryTransportStats } from "./transport.js";
5
6
 
6
7
  /**
7
8
  * A WebQuery request that failed with an HTTP status and no TeamSpeak error in
@@ -61,7 +62,11 @@ export type WebCredentials =
61
62
 
62
63
  /** How a WebQuery is reached. */
63
64
  export type WebQueryOptions = WebCredentials & {
64
- /** The server's base URL, such as `http://127.0.0.1:10080`. */
65
+ /**
66
+ * The server's base URL, such as `http://127.0.0.1:10080` or
67
+ * `https://ts.example.com:10443`. The default `fetch` speaks `http:` and
68
+ * `https:` and refuses any other scheme.
69
+ */
65
70
  readonly url: string;
66
71
  /**
67
72
  * The virtual server to address; `use` changes it. Unset leaves the server out
@@ -71,9 +76,16 @@ export type WebQueryOptions = WebCredentials & {
71
76
  */
72
77
  readonly serverID?: number | bigint | undefined;
73
78
  /**
74
- * The `fetch` to use. By default a small one over `node:http`: the global
75
- * `fetch` refuses port 10080, WebQuery's own default, as a port the Fetch
76
- * standard blocks.
79
+ * The `fetch` to use. By default a small one over `node:http` for an `http:`
80
+ * URL and `node:https` for an `https:` one: the global `fetch` refuses port
81
+ * 10080, WebQuery's own default, as a port the Fetch standard blocks.
82
+ *
83
+ * Over HTTPS the default verifies the server's certificate against the
84
+ * trust store Node uses, and never skips that. A server whose certificate
85
+ * comes from a private CA, or is self-signed, is trusted one of two ways:
86
+ * start Node with `NODE_EXTRA_CA_CERTS=ca.pem`, or pass a `fetch` that
87
+ * trusts the CA, such as one over `node:https` with an `https.Agent` whose
88
+ * `ca` is that certificate.
77
89
  */
78
90
  readonly fetch?: typeof fetch | undefined;
79
91
  };
@@ -113,6 +125,9 @@ export class WebQueryTransport implements QueryTransport {
113
125
  #prefix: string;
114
126
  #fetch: typeof fetch;
115
127
  #closed = false;
128
+ #openedAt = new Date();
129
+ #bytesSent = 0;
130
+ #bytesReceived = 0;
116
131
 
117
132
  constructor(options: WebQueryOptions) {
118
133
  this.#base = options.url.replace(/\/+$/, "");
@@ -127,7 +142,15 @@ export class WebQueryTransport implements QueryTransport {
127
142
  }
128
143
  this.#apiKey = options.apiKey;
129
144
  this.#prefix = options.serverID === undefined ? "" : `/${options.serverID}`;
130
- this.#fetch = options.fetch ?? httpFetch;
145
+ if (options.fetch === undefined) {
146
+ const { protocol } = new URL(options.url);
147
+ if (protocol !== "http:" && protocol !== "https:") {
148
+ throw new TypeError(
149
+ `WebQuery is reached over http: or https:, not ${protocol}; pass a fetch to use another scheme`,
150
+ );
151
+ }
152
+ }
153
+ this.#fetch = options.fetch ?? nodeFetch;
131
154
  }
132
155
 
133
156
  async send(line: string): Promise<void> {
@@ -143,26 +166,30 @@ export class WebQueryTransport implements QueryTransport {
143
166
  }
144
167
 
145
168
  const params = rows.map((row) => ({ ...row.params }));
169
+ const body = JSON.stringify(params.length === 1 ? params[0] : params);
146
170
  const response = await this.#fetch(`${this.#base}${this.#prefix}/${name}`, {
147
171
  method: "POST",
148
172
  headers: {
149
173
  ...(this.#apiKey === undefined ? {} : { "x-api-key": this.#apiKey }),
150
174
  "content-type": "application/json",
151
175
  },
152
- body: JSON.stringify(params.length === 1 ? params[0] : params),
176
+ body,
153
177
  });
178
+ this.#bytesSent += Buffer.byteLength(body);
154
179
 
155
180
  let answer: WebAnswer | undefined;
156
181
  try {
157
- answer = (await response.json()) as WebAnswer;
182
+ const text = await response.text();
183
+ this.#bytesReceived += Buffer.byteLength(text);
184
+ answer = JSON.parse(text) as WebAnswer;
158
185
  } catch {
159
186
  // Not JSON: nothing the server said about the command.
160
187
  }
161
188
  const code = answer?.status?.code;
162
189
  if (typeof code !== "number") throw new WebQueryHttpError(response.status, response.statusText);
163
190
 
164
- const body = answer?.body;
165
- for (const row of Array.isArray(body) ? body : []) {
191
+ const rowsBody = answer?.body;
192
+ for (const row of Array.isArray(rowsBody) ? rowsBody : []) {
166
193
  if (typeof row === "object" && row !== null) this.onLine?.(rowLine(row));
167
194
  }
168
195
  const message = answer?.status?.message;
@@ -177,6 +204,20 @@ export class WebQueryTransport implements QueryTransport {
177
204
  );
178
205
  }
179
206
 
207
+ stats(): QueryTransportStats {
208
+ // The base URL as given, without a user or password a caller put in it.
209
+ const url = new URL(this.#base);
210
+ url.username = "";
211
+ url.password = "";
212
+ return {
213
+ connectedAt: this.#openedAt,
214
+ transport: "web",
215
+ address: url.toString().replace(/\/+$/, ""),
216
+ bytesSent: this.#bytesSent,
217
+ bytesReceived: this.#bytesReceived,
218
+ };
219
+ }
220
+
180
221
  close(): Promise<void> {
181
222
  if (this.#closed) return Promise.resolve();
182
223
  this.#closed = true;
@@ -213,11 +254,13 @@ function rowLine(row: object): string {
213
254
  }
214
255
 
215
256
  /**
216
- * The part of `fetch` this transport uses, over `node:http`: a POST with a
217
- * string body, answered with a `Response`. Plain HTTP only, as WebQuery is.
257
+ * The part of `fetch` this transport uses, over `node:http` or `node:https`
258
+ * by the URL's scheme: a POST with a string body, answered with a `Response`.
259
+ * An HTTPS certificate is verified the way Node does by default.
218
260
  */
219
- const httpFetch = ((url: string, init: RequestInit) =>
261
+ const nodeFetch = ((url: string, init: RequestInit) =>
220
262
  new Promise<Response>((resolve, reject) => {
263
+ const request = new URL(url).protocol === "https:" ? httpsRequest : httpRequest;
221
264
  const req = request(
222
265
  url,
223
266
  { method: init.method, headers: init.headers as Record<string, string> },