@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.
- package/README.md +40 -5
- package/dist/client/capabilities.d.ts +4 -4
- package/dist/client/capabilities.d.ts.map +1 -1
- package/dist/client/capabilities.js +3 -8
- package/dist/client/capabilities.js.map +1 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +50 -9
- package/dist/client/client.js.map +1 -1
- package/dist/client/crypto/identity.d.ts +2 -0
- package/dist/client/crypto/identity.d.ts.map +1 -1
- package/dist/client/crypto/identity.js +2 -2
- 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 -1
- package/dist/client/crypto/index.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/trace.d.ts +7 -0
- package/dist/client/trace.d.ts.map +1 -1
- package/dist/client/transport/handler.d.ts +2 -0
- package/dist/client/transport/handler.d.ts.map +1 -1
- package/dist/client/transport/handler.js +4 -0
- package/dist/client/transport/handler.js.map +1 -1
- package/dist/client/types.d.ts +9 -2
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -1
- package/dist/client/view.d.ts +3 -3
- package/dist/client/view.d.ts.map +1 -1
- package/dist/client/view.js.map +1 -1
- package/dist/core/api/channels.d.ts +7 -3
- package/dist/core/api/channels.d.ts.map +1 -1
- package/dist/core/api/channels.js +5 -3
- package/dist/core/api/channels.js.map +1 -1
- package/dist/core/api/clients.d.ts +38 -1
- package/dist/core/api/clients.d.ts.map +1 -1
- package/dist/core/api/clients.js +33 -2
- package/dist/core/api/clients.js.map +1 -1
- package/dist/core/api/command.d.ts +18 -1
- package/dist/core/api/command.d.ts.map +1 -1
- package/dist/core/api/command.js +7 -1
- package/dist/core/api/command.js.map +1 -1
- package/dist/core/api/instance.d.ts +10 -2
- package/dist/core/api/instance.d.ts.map +1 -1
- package/dist/core/api/instance.js +6 -2
- package/dist/core/api/instance.js.map +1 -1
- package/dist/core/api/moderation.d.ts +7 -0
- package/dist/core/api/moderation.d.ts.map +1 -1
- package/dist/core/api/moderation.js +7 -0
- package/dist/core/api/moderation.js.map +1 -1
- package/dist/core/api/parse.d.ts.map +1 -1
- package/dist/core/api/parse.js +1 -0
- package/dist/core/api/parse.js.map +1 -1
- package/dist/core/api/server.d.ts +9 -0
- package/dist/core/api/server.d.ts.map +1 -1
- package/dist/core/api/server.js +3 -0
- package/dist/core/api/server.js.map +1 -1
- package/dist/core/command/responses.d.ts.map +1 -1
- package/dist/core/command/responses.js +1 -0
- package/dist/core/command/responses.js.map +1 -1
- package/dist/core/events.d.ts +47 -5
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/notification-name.d.ts +4 -0
- package/dist/core/notification-name.d.ts.map +1 -1
- package/dist/core/notification-name.js +4 -0
- package/dist/core/notification-name.js.map +1 -1
- package/dist/core/notifications.d.ts +6 -2
- package/dist/core/notifications.d.ts.map +1 -1
- package/dist/core/notifications.js +32 -1
- package/dist/core/notifications.js.map +1 -1
- package/dist/core/types.d.ts +7 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +16 -0
- package/dist/core/types.js.map +1 -1
- package/dist/query/client.d.ts +12 -3
- package/dist/query/client.d.ts.map +1 -1
- package/dist/query/client.js +22 -15
- package/dist/query/client.js.map +1 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/options.d.ts +17 -5
- package/dist/query/options.d.ts.map +1 -1
- package/dist/query/options.js +1 -0
- package/dist/query/options.js.map +1 -1
- package/dist/query/ssh.d.ts.map +1 -1
- package/dist/query/ssh.js +4 -1
- package/dist/query/ssh.js.map +1 -1
- package/dist/query/stream.d.ts +8 -3
- package/dist/query/stream.d.ts.map +1 -1
- package/dist/query/stream.js +26 -3
- package/dist/query/stream.js.map +1 -1
- package/dist/query/tcp.js +1 -1
- package/dist/query/tcp.js.map +1 -1
- package/dist/query/transport.d.ts +35 -0
- package/dist/query/transport.d.ts.map +1 -1
- package/dist/query/web.d.ts +17 -5
- package/dist/query/web.d.ts.map +1 -1
- package/dist/query/web.js +38 -9
- package/dist/query/web.js.map +1 -1
- package/package.json +6 -5
- package/src/client/capabilities.ts +14 -11
- package/src/client/client.ts +47 -6
- package/src/client/crypto/identity.ts +2 -2
- package/src/client/crypto/index.ts +1 -0
- package/src/client/index.ts +2 -0
- package/src/client/trace.ts +7 -0
- package/src/client/transport/handler.ts +5 -0
- package/src/client/types.ts +9 -0
- package/src/client/view.ts +2 -3
- package/src/core/api/channels.ts +9 -3
- package/src/core/api/clients.ts +73 -2
- package/src/core/api/command.ts +22 -1
- package/src/core/api/instance.ts +20 -2
- package/src/core/api/moderation.ts +7 -0
- package/src/core/api/parse.ts +1 -0
- package/src/core/api/server.ts +12 -0
- package/src/core/command/responses.ts +1 -0
- package/src/core/events.ts +48 -5
- package/src/core/index.ts +2 -1
- package/src/core/notification-name.ts +4 -0
- package/src/core/notifications.ts +48 -17
- package/src/core/types.ts +14 -0
- package/src/query/client.ts +28 -15
- package/src/query/index.ts +1 -1
- package/src/query/options.ts +19 -5
- package/src/query/ssh.ts +6 -1
- package/src/query/stream.ts +31 -4
- package/src/query/tcp.ts +1 -1
- package/src/query/testing/live.ts +311 -0
- package/src/query/testing/ssh-keys.ts +67 -0
- package/src/query/transport.ts +36 -0
- 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
|
+
}
|
package/src/query/transport.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
75
|
-
* `
|
|
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
|
-
|
|
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
|
|
176
|
+
body,
|
|
153
177
|
});
|
|
178
|
+
this.#bytesSent += Buffer.byteLength(body);
|
|
154
179
|
|
|
155
180
|
let answer: WebAnswer | undefined;
|
|
156
181
|
try {
|
|
157
|
-
|
|
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
|
|
165
|
-
for (const row of Array.isArray(
|
|
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
|
|
217
|
-
* string body, answered with a `Response`.
|
|
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
|
|
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> },
|