@serverkgg/bridge 0.0.13 → 0.0.14
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/package.json +1 -1
- package/src/context/bridgeContext.ts +9 -2
- package/src/manifest/manifest.ts +1 -1
- package/src/modules/bridgeModule.ts +2 -1
- package/src/rcon/index.ts +3 -2
- package/src/rcon/rconAccess.test.ts +286 -0
- package/src/rcon/rconAccess.ts +252 -0
- package/src/rcon/rconClient.test.ts +1544 -0
- package/src/rcon/rconClient.ts +275 -0
- package/src/rcon/rconError.ts +21 -0
- package/src/rcon/rconFrame.test.ts +269 -0
- package/src/rcon/{sourceRconFrame.ts → rconFrame.ts} +23 -14
- package/src/rcon/rconIds.test.ts +59 -0
- package/src/rcon/rconIds.ts +33 -0
- package/src/rcon/rconLink.ts +427 -0
- package/src/rcon/rconWriter.test.ts +95 -0
- package/src/rcon/rconWriter.ts +63 -0
- package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
- package/src/runtime/bridgeConsoleHandler.ts +26 -0
- package/src/runtime/bridgeRcon.ts +6 -2
- package/src/runtime/serve.ts +10 -1
- package/src/rcon/sourceRcon.test.ts +0 -308
- package/src/rcon/sourceRcon.ts +0 -276
- package/src/rcon/sourceRconFrame.test.ts +0 -152
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Socket } from "bun";
|
|
3
|
+
import { BridgeRconCode, BridgeRconError } from "../core";
|
|
4
|
+
import { RconClient } from "./rconClient";
|
|
5
|
+
import {
|
|
6
|
+
concatBytes,
|
|
7
|
+
decodeRconFrames,
|
|
8
|
+
encodeRconFrame,
|
|
9
|
+
RCON_AUTH_FAILED_ID,
|
|
10
|
+
RCON_BODY_LIMIT,
|
|
11
|
+
RCON_REPLY_LIMIT,
|
|
12
|
+
RCON_SPLIT_THRESHOLD,
|
|
13
|
+
type RconFrame,
|
|
14
|
+
RconRequest,
|
|
15
|
+
RconResponse,
|
|
16
|
+
} from "./rconFrame";
|
|
17
|
+
import { createRconWriter, type RconWriter } from "./rconWriter";
|
|
18
|
+
|
|
19
|
+
const HOST = "127.0.0.1";
|
|
20
|
+
|
|
21
|
+
const CLOSED_PORT = 59_433;
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "kX7-forbidden-in-errors";
|
|
24
|
+
|
|
25
|
+
const LAST_ID = 2_147_483_646;
|
|
26
|
+
|
|
27
|
+
const EMPTY = new Uint8Array(0);
|
|
28
|
+
|
|
29
|
+
const TRAILER_BODY = String.fromCharCode(0, 1, 0, 0);
|
|
30
|
+
|
|
31
|
+
const LONG_BODY = "a".repeat(RCON_SPLIT_THRESHOLD);
|
|
32
|
+
|
|
33
|
+
type FakeSocket = Socket<undefined>;
|
|
34
|
+
|
|
35
|
+
interface RconPeer {
|
|
36
|
+
frame(frame: RconFrame): void;
|
|
37
|
+
raw(bytes: Uint8Array): void;
|
|
38
|
+
end(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RconScript {
|
|
42
|
+
strict?: boolean;
|
|
43
|
+
auth?(frame: RconFrame, peer: RconPeer): void;
|
|
44
|
+
command?(frame: RconFrame, peer: RconPeer): void;
|
|
45
|
+
probe?(frame: RconFrame, peer: RconPeer): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RconFake {
|
|
49
|
+
readonly port: number;
|
|
50
|
+
readonly auths: number;
|
|
51
|
+
readonly probes: number;
|
|
52
|
+
readonly closes: number;
|
|
53
|
+
readonly commands: string[];
|
|
54
|
+
readonly frames: RconFrame[];
|
|
55
|
+
password: string;
|
|
56
|
+
drop(): void;
|
|
57
|
+
stop(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const createFake = (script: RconScript = {}): RconFake => {
|
|
61
|
+
const commands: string[] = [];
|
|
62
|
+
const frames: RconFrame[] = [];
|
|
63
|
+
const peers = new Set<FakeSocket>();
|
|
64
|
+
const buffers = new Map<FakeSocket, Uint8Array>();
|
|
65
|
+
const writers = new Map<FakeSocket, RconWriter>();
|
|
66
|
+
|
|
67
|
+
let auths = 0;
|
|
68
|
+
let probes = 0;
|
|
69
|
+
let closes = 0;
|
|
70
|
+
|
|
71
|
+
const peerOf = (socket: FakeSocket): RconPeer => {
|
|
72
|
+
const writer = writers.get(socket);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
frame(out) {
|
|
76
|
+
writer?.send(encodeRconFrame(out));
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
raw(bytes) {
|
|
80
|
+
writer?.send(bytes);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
end() {
|
|
84
|
+
socket.end();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const onAuth =
|
|
90
|
+
script.auth
|
|
91
|
+
?? ((frame: RconFrame, peer: RconPeer) => {
|
|
92
|
+
peer.frame({
|
|
93
|
+
id: frame.id,
|
|
94
|
+
type: RconResponse.Value,
|
|
95
|
+
body: "",
|
|
96
|
+
});
|
|
97
|
+
peer.frame({
|
|
98
|
+
id: frame.body === fake.password ? frame.id : RCON_AUTH_FAILED_ID,
|
|
99
|
+
type: RconResponse.Auth,
|
|
100
|
+
body: "",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const onCommand =
|
|
105
|
+
script.command
|
|
106
|
+
?? ((frame: RconFrame, peer: RconPeer) => {
|
|
107
|
+
peer.frame({
|
|
108
|
+
id: frame.id,
|
|
109
|
+
type: RconResponse.Value,
|
|
110
|
+
body: `ran ${frame.body}`,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const onProbe =
|
|
115
|
+
script.probe
|
|
116
|
+
?? ((frame: RconFrame, peer: RconPeer) => {
|
|
117
|
+
peer.frame({
|
|
118
|
+
id: frame.id,
|
|
119
|
+
type: RconResponse.Value,
|
|
120
|
+
body: "",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const server = Bun.listen({
|
|
125
|
+
hostname: HOST,
|
|
126
|
+
port: 0,
|
|
127
|
+
socket: {
|
|
128
|
+
binaryType: "uint8array",
|
|
129
|
+
open(socket) {
|
|
130
|
+
peers.add(socket);
|
|
131
|
+
writers.set(
|
|
132
|
+
socket,
|
|
133
|
+
createRconWriter((bytes) => socket.write(bytes)),
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
close(socket) {
|
|
137
|
+
peers.delete(socket);
|
|
138
|
+
buffers.delete(socket);
|
|
139
|
+
writers.delete(socket);
|
|
140
|
+
|
|
141
|
+
closes += 1;
|
|
142
|
+
},
|
|
143
|
+
error() {},
|
|
144
|
+
drain(socket) {
|
|
145
|
+
writers.get(socket)?.drain();
|
|
146
|
+
},
|
|
147
|
+
data(socket, chunk) {
|
|
148
|
+
if (script.strict && chunk.length >= 4) {
|
|
149
|
+
const declared = new DataView(chunk.buffer, chunk.byteOffset, 4).getInt32(0, true);
|
|
150
|
+
|
|
151
|
+
if (declared !== chunk.length - 4) {
|
|
152
|
+
socket.end();
|
|
153
|
+
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const decoded = decodeRconFrames(concatBytes(buffers.get(socket) ?? EMPTY, chunk));
|
|
159
|
+
|
|
160
|
+
buffers.set(socket, decoded.rest.slice());
|
|
161
|
+
|
|
162
|
+
const peer = peerOf(socket);
|
|
163
|
+
|
|
164
|
+
for (const frame of decoded.frames) {
|
|
165
|
+
frames.push(frame);
|
|
166
|
+
|
|
167
|
+
if (frame.type === RconRequest.Auth) {
|
|
168
|
+
auths += 1;
|
|
169
|
+
|
|
170
|
+
onAuth(frame, peer);
|
|
171
|
+
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (frame.type === RconRequest.Command) {
|
|
176
|
+
commands.push(frame.body);
|
|
177
|
+
|
|
178
|
+
onCommand(frame, peer);
|
|
179
|
+
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
probes += 1;
|
|
184
|
+
|
|
185
|
+
onProbe(frame, peer);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const fake: RconFake = {
|
|
192
|
+
password: PASSWORD,
|
|
193
|
+
commands,
|
|
194
|
+
frames,
|
|
195
|
+
|
|
196
|
+
get port() {
|
|
197
|
+
return server.port;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
get auths() {
|
|
201
|
+
return auths;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
get probes() {
|
|
205
|
+
return probes;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
get closes() {
|
|
209
|
+
return closes;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
drop() {
|
|
213
|
+
for (const peer of peers) {
|
|
214
|
+
peer.end();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
stop() {
|
|
219
|
+
server.stop(true);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return fake;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const until = async (check: () => boolean) => {
|
|
227
|
+
for (let attempt = 0; attempt < 400; attempt += 1) {
|
|
228
|
+
if (check()) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await Bun.sleep(5);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw new Error("the fake never reached the expected state");
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const failed = async (work: Promise<unknown>, code: BridgeRconCode) => {
|
|
239
|
+
const error = await work.catch((reason: unknown) => reason);
|
|
240
|
+
|
|
241
|
+
expect(error).toBeInstanceOf(BridgeRconError);
|
|
242
|
+
expect((error as BridgeRconError).code).toBe(code);
|
|
243
|
+
|
|
244
|
+
return error as BridgeRconError;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const commandIds = (fake: RconFake) => {
|
|
248
|
+
return fake.frames.filter((frame) => frame.type === RconRequest.Command).map((frame) => frame.id);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const connect = (fake: RconFake, options: Partial<ConstructorParameters<typeof RconClient>[0]> = {}) => {
|
|
252
|
+
return new RconClient({
|
|
253
|
+
host: HOST,
|
|
254
|
+
port: fake.port,
|
|
255
|
+
password: PASSWORD,
|
|
256
|
+
timeoutMs: 2000,
|
|
257
|
+
...options,
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
describe("RconClient authentication", () => {
|
|
262
|
+
test("connects through the empty preamble and the auth reply", async () => {
|
|
263
|
+
const fake = createFake();
|
|
264
|
+
const client = connect(fake);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await client.connect();
|
|
268
|
+
|
|
269
|
+
expect(client.connected).toBe(true);
|
|
270
|
+
expect(fake.auths).toBe(1);
|
|
271
|
+
} finally {
|
|
272
|
+
await client.disconnect();
|
|
273
|
+
|
|
274
|
+
fake.stop();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("connects when the server answers with the auth reply only", async () => {
|
|
279
|
+
const fake = createFake({
|
|
280
|
+
auth(frame, peer) {
|
|
281
|
+
peer.frame({
|
|
282
|
+
id: frame.id,
|
|
283
|
+
type: RconResponse.Auth,
|
|
284
|
+
body: "",
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const client = connect(fake);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await client.connect();
|
|
293
|
+
|
|
294
|
+
expect(client.connected).toBe(true);
|
|
295
|
+
} finally {
|
|
296
|
+
await client.disconnect();
|
|
297
|
+
|
|
298
|
+
fake.stop();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("rejects a wrong password and ends the socket", async () => {
|
|
303
|
+
const fake = createFake();
|
|
304
|
+
|
|
305
|
+
fake.password = "another-password";
|
|
306
|
+
|
|
307
|
+
const client = connect(fake);
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await failed(client.connect(), BridgeRconCode.AuthFailed);
|
|
311
|
+
await until(() => fake.closes === 1);
|
|
312
|
+
} finally {
|
|
313
|
+
fake.stop();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("reports a server that closes during authentication", async () => {
|
|
318
|
+
const fake = createFake({
|
|
319
|
+
auth(_frame, peer) {
|
|
320
|
+
peer.end();
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const client = connect(fake);
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const error = await failed(client.connect(), BridgeRconCode.Closed);
|
|
328
|
+
|
|
329
|
+
expect(error.message).toContain("authentication");
|
|
330
|
+
} finally {
|
|
331
|
+
fake.stop();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("times out an authentication the server never answers", async () => {
|
|
336
|
+
const fake = createFake({
|
|
337
|
+
auth() {},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const client = connect(fake, {
|
|
341
|
+
timeoutMs: 250,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await failed(client.connect(), BridgeRconCode.TimedOut);
|
|
346
|
+
} finally {
|
|
347
|
+
fake.stop();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("dials once for two sequential connect calls", async () => {
|
|
352
|
+
const fake = createFake();
|
|
353
|
+
const client = connect(fake);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await client.connect();
|
|
357
|
+
await client.connect();
|
|
358
|
+
|
|
359
|
+
expect(fake.auths).toBe(1);
|
|
360
|
+
} finally {
|
|
361
|
+
await client.disconnect();
|
|
362
|
+
|
|
363
|
+
fake.stop();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("never starts a third dial when an abandoned dial settles", async () => {
|
|
368
|
+
let dials = 0;
|
|
369
|
+
|
|
370
|
+
const fake = createFake({
|
|
371
|
+
auth(frame, peer) {
|
|
372
|
+
dials += 1;
|
|
373
|
+
|
|
374
|
+
const delay = dials === 1 ? 200 : 400;
|
|
375
|
+
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
peer.frame({
|
|
378
|
+
id: frame.id,
|
|
379
|
+
type: RconResponse.Value,
|
|
380
|
+
body: "",
|
|
381
|
+
});
|
|
382
|
+
peer.frame({
|
|
383
|
+
id: frame.id,
|
|
384
|
+
type: RconResponse.Auth,
|
|
385
|
+
body: "",
|
|
386
|
+
});
|
|
387
|
+
}, delay);
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const client = connect(fake, {
|
|
392
|
+
timeoutMs: 3000,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const abandoned = client.connect().catch((reason: unknown) => reason);
|
|
397
|
+
|
|
398
|
+
await client.disconnect();
|
|
399
|
+
|
|
400
|
+
const one = client.command("one");
|
|
401
|
+
|
|
402
|
+
await Bun.sleep(300);
|
|
403
|
+
|
|
404
|
+
const two = client.command("two");
|
|
405
|
+
const shared = client.connect();
|
|
406
|
+
|
|
407
|
+
expect(await abandoned).toBeInstanceOf(BridgeRconError);
|
|
408
|
+
expect(await one).toBe("ran one");
|
|
409
|
+
expect(await two).toBe("ran two");
|
|
410
|
+
|
|
411
|
+
await shared;
|
|
412
|
+
|
|
413
|
+
expect(fake.auths).toBe(2);
|
|
414
|
+
} finally {
|
|
415
|
+
await client.disconnect();
|
|
416
|
+
|
|
417
|
+
fake.stop();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("shares one dial between concurrent connect calls", async () => {
|
|
422
|
+
const fake = createFake();
|
|
423
|
+
const client = connect(fake);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
await Promise.all([
|
|
427
|
+
client.connect(),
|
|
428
|
+
client.connect(),
|
|
429
|
+
client.connect(),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
expect(fake.auths).toBe(1);
|
|
433
|
+
} finally {
|
|
434
|
+
await client.disconnect();
|
|
435
|
+
|
|
436
|
+
fake.stop();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("RconClient packet ids", () => {
|
|
442
|
+
test("sends an increasing id for every command", async () => {
|
|
443
|
+
const fake = createFake();
|
|
444
|
+
const client = connect(fake);
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
await client.command("one");
|
|
448
|
+
await client.command("two");
|
|
449
|
+
await client.command("three");
|
|
450
|
+
|
|
451
|
+
const ids = commandIds(fake);
|
|
452
|
+
|
|
453
|
+
expect(ids).toHaveLength(3);
|
|
454
|
+
expect(ids.at(1)).toBeGreaterThan(Number(ids.at(0)));
|
|
455
|
+
expect(ids.at(2)).toBeGreaterThan(Number(ids.at(1)));
|
|
456
|
+
} finally {
|
|
457
|
+
await client.disconnect();
|
|
458
|
+
|
|
459
|
+
fake.stop();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("wraps its ids without ever sending zero or minus one", async () => {
|
|
464
|
+
const fake = createFake();
|
|
465
|
+
|
|
466
|
+
const client = connect(fake, {
|
|
467
|
+
idSeed: LAST_ID - 3,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
await client.command("one");
|
|
472
|
+
await client.command("two");
|
|
473
|
+
|
|
474
|
+
expect(commandIds(fake)).toEqual([
|
|
475
|
+
LAST_ID - 3,
|
|
476
|
+
1,
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
for (const frame of fake.frames) {
|
|
480
|
+
expect(frame.id).toBeGreaterThan(0);
|
|
481
|
+
}
|
|
482
|
+
} finally {
|
|
483
|
+
await client.disconnect();
|
|
484
|
+
|
|
485
|
+
fake.stop();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("ignores a reply carrying a foreign id", async () => {
|
|
490
|
+
const fake = createFake({
|
|
491
|
+
command(frame, peer) {
|
|
492
|
+
peer.frame({
|
|
493
|
+
id: frame.id + 5000,
|
|
494
|
+
type: RconResponse.Value,
|
|
495
|
+
body: "someone else",
|
|
496
|
+
});
|
|
497
|
+
peer.frame({
|
|
498
|
+
id: frame.id,
|
|
499
|
+
type: RconResponse.Value,
|
|
500
|
+
body: "mine",
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const client = connect(fake);
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
expect(await client.command("status")).toBe("mine");
|
|
509
|
+
} finally {
|
|
510
|
+
await client.disconnect();
|
|
511
|
+
|
|
512
|
+
fake.stop();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("ignores unsolicited keep-alive frames around a reply", async () => {
|
|
517
|
+
const fake = createFake({
|
|
518
|
+
command(frame, peer) {
|
|
519
|
+
peer.frame({
|
|
520
|
+
id: 0,
|
|
521
|
+
type: RconResponse.Value,
|
|
522
|
+
body: "Keep Alive",
|
|
523
|
+
});
|
|
524
|
+
peer.frame({
|
|
525
|
+
id: frame.id,
|
|
526
|
+
type: RconResponse.Value,
|
|
527
|
+
body: LONG_BODY,
|
|
528
|
+
});
|
|
529
|
+
peer.frame({
|
|
530
|
+
id: 0,
|
|
531
|
+
type: RconResponse.Value,
|
|
532
|
+
body: "Keep Alive",
|
|
533
|
+
});
|
|
534
|
+
peer.frame({
|
|
535
|
+
id: frame.id,
|
|
536
|
+
type: RconResponse.Value,
|
|
537
|
+
body: "second",
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const client = connect(fake);
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
expect(await client.command("listplayers")).toBe(`${LONG_BODY}second`);
|
|
546
|
+
} finally {
|
|
547
|
+
await client.disconnect();
|
|
548
|
+
|
|
549
|
+
fake.stop();
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("ignores a frame of an unknown type", async () => {
|
|
554
|
+
const fake = createFake({
|
|
555
|
+
command(frame, peer) {
|
|
556
|
+
peer.frame({
|
|
557
|
+
id: frame.id,
|
|
558
|
+
type: 4,
|
|
559
|
+
body: "webrcon noise",
|
|
560
|
+
});
|
|
561
|
+
peer.frame({
|
|
562
|
+
id: frame.id,
|
|
563
|
+
type: RconResponse.Value,
|
|
564
|
+
body: "clean",
|
|
565
|
+
});
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const client = connect(fake);
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
expect(await client.command("status")).toBe("clean");
|
|
573
|
+
} finally {
|
|
574
|
+
await client.disconnect();
|
|
575
|
+
|
|
576
|
+
fake.stop();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("RconClient fragmentation", () => {
|
|
582
|
+
test("joins a reply written one byte at a time", async () => {
|
|
583
|
+
const fake = createFake({
|
|
584
|
+
command(frame, peer) {
|
|
585
|
+
const bytes = encodeRconFrame({
|
|
586
|
+
id: frame.id,
|
|
587
|
+
type: RconResponse.Value,
|
|
588
|
+
body: "byte by byte",
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
for (const byte of bytes) {
|
|
592
|
+
peer.raw(
|
|
593
|
+
new Uint8Array([
|
|
594
|
+
byte,
|
|
595
|
+
]),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const client = connect(fake);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
expect(await client.command("status")).toBe("byte by byte");
|
|
605
|
+
} finally {
|
|
606
|
+
await client.disconnect();
|
|
607
|
+
|
|
608
|
+
fake.stop();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("reads two frames delivered in one chunk", async () => {
|
|
613
|
+
const fake = createFake({
|
|
614
|
+
command(frame, peer) {
|
|
615
|
+
peer.raw(
|
|
616
|
+
concatBytes(
|
|
617
|
+
encodeRconFrame({
|
|
618
|
+
id: frame.id,
|
|
619
|
+
type: RconResponse.Value,
|
|
620
|
+
body: LONG_BODY,
|
|
621
|
+
}),
|
|
622
|
+
encodeRconFrame({
|
|
623
|
+
id: frame.id,
|
|
624
|
+
type: RconResponse.Value,
|
|
625
|
+
body: "one chunk",
|
|
626
|
+
}),
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const client = connect(fake);
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
expect(await client.command("status")).toBe(`${LONG_BODY}one chunk`);
|
|
636
|
+
} finally {
|
|
637
|
+
await client.disconnect();
|
|
638
|
+
|
|
639
|
+
fake.stop();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("joins a reply split at the body limit", async () => {
|
|
644
|
+
const fake = createFake({
|
|
645
|
+
command(frame, peer) {
|
|
646
|
+
peer.frame({
|
|
647
|
+
id: frame.id,
|
|
648
|
+
type: RconResponse.Value,
|
|
649
|
+
body: "a".repeat(RCON_BODY_LIMIT),
|
|
650
|
+
});
|
|
651
|
+
peer.frame({
|
|
652
|
+
id: frame.id,
|
|
653
|
+
type: RconResponse.Value,
|
|
654
|
+
body: "b".repeat(RCON_BODY_LIMIT),
|
|
655
|
+
});
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const client = connect(fake);
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
expect(await client.command("status")).toHaveLength(RCON_BODY_LIMIT * 2);
|
|
663
|
+
} finally {
|
|
664
|
+
await client.disconnect();
|
|
665
|
+
|
|
666
|
+
fake.stop();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe("RconClient multi-packet replies", () => {
|
|
672
|
+
test("settles a short reply without ever sending a probe", async () => {
|
|
673
|
+
const fake = createFake();
|
|
674
|
+
const client = connect(fake);
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
expect(await client.command("list")).toBe("ran list");
|
|
678
|
+
expect(fake.probes).toBe(0);
|
|
679
|
+
} finally {
|
|
680
|
+
await client.disconnect();
|
|
681
|
+
|
|
682
|
+
fake.stop();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("joins every value frame in order at the probe echo", async () => {
|
|
687
|
+
const fake = createFake({
|
|
688
|
+
command(frame, peer) {
|
|
689
|
+
for (const part of [
|
|
690
|
+
LONG_BODY,
|
|
691
|
+
"second-",
|
|
692
|
+
"third",
|
|
693
|
+
]) {
|
|
694
|
+
peer.frame({
|
|
695
|
+
id: frame.id,
|
|
696
|
+
type: RconResponse.Value,
|
|
697
|
+
body: part,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const client = connect(fake);
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
expect(await client.command("status")).toBe(`${LONG_BODY}second-third`);
|
|
707
|
+
} finally {
|
|
708
|
+
await client.disconnect();
|
|
709
|
+
|
|
710
|
+
fake.stop();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("drops the srcds trailer that follows the echo", async () => {
|
|
715
|
+
const fake = createFake({
|
|
716
|
+
command(frame, peer) {
|
|
717
|
+
peer.frame({
|
|
718
|
+
id: frame.id,
|
|
719
|
+
type: RconResponse.Value,
|
|
720
|
+
body: LONG_BODY,
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
probe(frame, peer) {
|
|
724
|
+
peer.frame({
|
|
725
|
+
id: frame.id,
|
|
726
|
+
type: RconResponse.Value,
|
|
727
|
+
body: "",
|
|
728
|
+
});
|
|
729
|
+
peer.frame({
|
|
730
|
+
id: frame.id,
|
|
731
|
+
type: RconResponse.Value,
|
|
732
|
+
body: TRAILER_BODY,
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const client = connect(fake);
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
expect(await client.command("first")).toBe(LONG_BODY);
|
|
741
|
+
expect(await client.command("second")).toBe(LONG_BODY);
|
|
742
|
+
} finally {
|
|
743
|
+
await client.disconnect();
|
|
744
|
+
|
|
745
|
+
fake.stop();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("terminates on the minecraft echo without appending it", async () => {
|
|
750
|
+
const fake = createFake({
|
|
751
|
+
command(frame, peer) {
|
|
752
|
+
peer.frame({
|
|
753
|
+
id: frame.id,
|
|
754
|
+
type: RconResponse.Value,
|
|
755
|
+
body: LONG_BODY,
|
|
756
|
+
});
|
|
757
|
+
},
|
|
758
|
+
probe(frame, peer) {
|
|
759
|
+
peer.frame({
|
|
760
|
+
id: frame.id,
|
|
761
|
+
type: RconResponse.Value,
|
|
762
|
+
body: "Unknown request 0",
|
|
763
|
+
});
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const client = connect(fake);
|
|
768
|
+
|
|
769
|
+
try {
|
|
770
|
+
expect(await client.command("list")).toBe(LONG_BODY);
|
|
771
|
+
} finally {
|
|
772
|
+
await client.disconnect();
|
|
773
|
+
|
|
774
|
+
fake.stop();
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("settles on the first frame and sends no probe when multi-packet is off", async () => {
|
|
779
|
+
const fake = createFake({
|
|
780
|
+
command(frame, peer) {
|
|
781
|
+
peer.frame({
|
|
782
|
+
id: frame.id,
|
|
783
|
+
type: RconResponse.Value,
|
|
784
|
+
body: LONG_BODY,
|
|
785
|
+
});
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const client = connect(fake, {
|
|
790
|
+
multiPacket: false,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
expect(await client.command("/players")).toBe(LONG_BODY);
|
|
795
|
+
expect(fake.probes).toBe(0);
|
|
796
|
+
} finally {
|
|
797
|
+
await client.disconnect();
|
|
798
|
+
|
|
799
|
+
fake.stop();
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("never puts two frames in one write for a server that refuses them", async () => {
|
|
804
|
+
const fake = createFake({
|
|
805
|
+
strict: true,
|
|
806
|
+
command(frame, peer) {
|
|
807
|
+
if (frame.body === "long") {
|
|
808
|
+
peer.frame({
|
|
809
|
+
id: frame.id,
|
|
810
|
+
type: RconResponse.Value,
|
|
811
|
+
body: LONG_BODY,
|
|
812
|
+
});
|
|
813
|
+
peer.frame({
|
|
814
|
+
id: frame.id,
|
|
815
|
+
type: RconResponse.Value,
|
|
816
|
+
body: "tail",
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
peer.frame({
|
|
823
|
+
id: frame.id,
|
|
824
|
+
type: RconResponse.Value,
|
|
825
|
+
body: `ran ${frame.body}`,
|
|
826
|
+
});
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const client = connect(fake);
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
expect(await client.command("short")).toBe("ran short");
|
|
834
|
+
expect(await client.command("long")).toBe(`${LONG_BODY}tail`);
|
|
835
|
+
expect(fake.probes).toBe(1);
|
|
836
|
+
expect(fake.closes).toBe(0);
|
|
837
|
+
} finally {
|
|
838
|
+
await client.disconnect();
|
|
839
|
+
|
|
840
|
+
fake.stop();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
describe("RconClient unmirrored ids", () => {
|
|
846
|
+
const palworld = () => {
|
|
847
|
+
return createFake({
|
|
848
|
+
command(_frame, peer) {
|
|
849
|
+
peer.frame({
|
|
850
|
+
id: 0,
|
|
851
|
+
type: RconResponse.Value,
|
|
852
|
+
body: LONG_BODY,
|
|
853
|
+
});
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
test("times out when the server answers with an id it never sent", async () => {
|
|
859
|
+
const fake = palworld();
|
|
860
|
+
|
|
861
|
+
const client = connect(fake, {
|
|
862
|
+
timeoutMs: 250,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
await failed(client.command("Info"), BridgeRconCode.TimedOut);
|
|
867
|
+
} finally {
|
|
868
|
+
fake.stop();
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("settles on the first value frame and sends no probe when ids are not mirrored", async () => {
|
|
873
|
+
const fake = palworld();
|
|
874
|
+
|
|
875
|
+
const client = connect(fake, {
|
|
876
|
+
mirroredIds: false,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
expect(await client.command("Info")).toBe(LONG_BODY);
|
|
881
|
+
expect(fake.probes).toBe(0);
|
|
882
|
+
} finally {
|
|
883
|
+
await client.disconnect();
|
|
884
|
+
|
|
885
|
+
fake.stop();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("still drops a frame that arrives with no command in flight", async () => {
|
|
890
|
+
const fake = createFake({
|
|
891
|
+
auth(frame, peer) {
|
|
892
|
+
peer.frame({
|
|
893
|
+
id: frame.id,
|
|
894
|
+
type: RconResponse.Auth,
|
|
895
|
+
body: "",
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
setTimeout(() => {
|
|
899
|
+
peer.frame({
|
|
900
|
+
id: 0,
|
|
901
|
+
type: RconResponse.Value,
|
|
902
|
+
body: "unsolicited",
|
|
903
|
+
});
|
|
904
|
+
}, 20);
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
const client = connect(fake, {
|
|
909
|
+
mirroredIds: false,
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
await client.connect();
|
|
914
|
+
await Bun.sleep(60);
|
|
915
|
+
|
|
916
|
+
expect(await client.command("ShowPlayers")).toBe("ran ShowPlayers");
|
|
917
|
+
} finally {
|
|
918
|
+
await client.disconnect();
|
|
919
|
+
|
|
920
|
+
fake.stop();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
describe("RconClient failures", () => {
|
|
926
|
+
test("drops the link on a malformed frame and reconnects", async () => {
|
|
927
|
+
let broken = false;
|
|
928
|
+
|
|
929
|
+
const fake = createFake({
|
|
930
|
+
command(frame, peer) {
|
|
931
|
+
if (broken) {
|
|
932
|
+
peer.frame({
|
|
933
|
+
id: frame.id,
|
|
934
|
+
type: RconResponse.Value,
|
|
935
|
+
body: `ran ${frame.body}`,
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
broken = true;
|
|
942
|
+
|
|
943
|
+
const packet = new Uint8Array(14);
|
|
944
|
+
|
|
945
|
+
new DataView(packet.buffer).setInt32(0, 2, true);
|
|
946
|
+
|
|
947
|
+
peer.raw(packet);
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
const client = connect(fake);
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
await failed(client.command("first"), BridgeRconCode.Malformed);
|
|
955
|
+
|
|
956
|
+
expect(client.connected).toBe(false);
|
|
957
|
+
expect(await client.command("second")).toBe("ran second");
|
|
958
|
+
expect(fake.auths).toBe(2);
|
|
959
|
+
} finally {
|
|
960
|
+
await client.disconnect();
|
|
961
|
+
|
|
962
|
+
fake.stop();
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("reports a reply above the reply limit", async () => {
|
|
967
|
+
const fake = createFake({
|
|
968
|
+
command(frame, peer) {
|
|
969
|
+
for (let part = 0; part * RCON_BODY_LIMIT <= RCON_REPLY_LIMIT; part += 1) {
|
|
970
|
+
peer.frame({
|
|
971
|
+
id: frame.id,
|
|
972
|
+
type: RconResponse.Value,
|
|
973
|
+
body: "x".repeat(RCON_BODY_LIMIT),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
probe() {},
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
const client = connect(fake);
|
|
981
|
+
|
|
982
|
+
try {
|
|
983
|
+
await failed(client.command("status"), BridgeRconCode.Oversized);
|
|
984
|
+
} finally {
|
|
985
|
+
await client.disconnect();
|
|
986
|
+
|
|
987
|
+
fake.stop();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("refuses a body above the packet limit without touching the network", async () => {
|
|
992
|
+
const fake = createFake();
|
|
993
|
+
const client = connect(fake);
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
await failed(client.command("x".repeat(RCON_BODY_LIMIT + 1)), BridgeRconCode.Oversized);
|
|
997
|
+
|
|
998
|
+
expect(fake.auths).toBe(0);
|
|
999
|
+
} finally {
|
|
1000
|
+
await client.disconnect();
|
|
1001
|
+
|
|
1002
|
+
fake.stop();
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test("reports a port nothing listens on", async () => {
|
|
1007
|
+
const client = new RconClient({
|
|
1008
|
+
host: HOST,
|
|
1009
|
+
port: CLOSED_PORT,
|
|
1010
|
+
password: PASSWORD,
|
|
1011
|
+
timeoutMs: 1000,
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
await failed(client.connect(), BridgeRconCode.Unreachable);
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
describe("RconClient timeouts", () => {
|
|
1019
|
+
test("times out a command the server never answers and never resends it", async () => {
|
|
1020
|
+
const fake = createFake({
|
|
1021
|
+
command() {},
|
|
1022
|
+
probe() {},
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const client = connect(fake, {
|
|
1026
|
+
timeoutMs: 250,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
1030
|
+
await failed(client.command("status"), BridgeRconCode.TimedOut);
|
|
1031
|
+
|
|
1032
|
+
expect(fake.commands.filter((body) => body === "status")).toHaveLength(1);
|
|
1033
|
+
} finally {
|
|
1034
|
+
fake.stop();
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("honours a per-command timeout shorter than the default", async () => {
|
|
1039
|
+
const fake = createFake({
|
|
1040
|
+
command() {},
|
|
1041
|
+
probe() {},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const client = connect(fake, {
|
|
1045
|
+
timeoutMs: 5000,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
const startedAt = performance.now();
|
|
1050
|
+
|
|
1051
|
+
await failed(
|
|
1052
|
+
client.command("status", {
|
|
1053
|
+
timeoutMs: 200,
|
|
1054
|
+
}),
|
|
1055
|
+
BridgeRconCode.TimedOut,
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
expect(performance.now() - startedAt).toBeLessThan(2000);
|
|
1059
|
+
} finally {
|
|
1060
|
+
fake.stop();
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("honours a per-command timeout longer than the default", async () => {
|
|
1065
|
+
const fake = createFake({
|
|
1066
|
+
command(frame, peer) {
|
|
1067
|
+
setTimeout(() => {
|
|
1068
|
+
peer.frame({
|
|
1069
|
+
id: frame.id,
|
|
1070
|
+
type: RconResponse.Value,
|
|
1071
|
+
body: "late",
|
|
1072
|
+
});
|
|
1073
|
+
}, 400);
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const client = connect(fake, {
|
|
1078
|
+
timeoutMs: 200,
|
|
1079
|
+
multiPacket: false,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
try {
|
|
1083
|
+
expect(
|
|
1084
|
+
await client.command("status", {
|
|
1085
|
+
timeoutMs: 2000,
|
|
1086
|
+
}),
|
|
1087
|
+
).toBe("late");
|
|
1088
|
+
} finally {
|
|
1089
|
+
await client.disconnect();
|
|
1090
|
+
|
|
1091
|
+
fake.stop();
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test("times out a queued command on its own deadline without writing it", async () => {
|
|
1096
|
+
const fake = createFake({
|
|
1097
|
+
command() {},
|
|
1098
|
+
probe() {},
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
const client = connect(fake, {
|
|
1102
|
+
timeoutMs: 5000,
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
const stalled = client.command("stalled", {
|
|
1107
|
+
timeoutMs: 1000,
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
const queued = client.command("queued", {
|
|
1111
|
+
timeoutMs: 200,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await failed(queued, BridgeRconCode.TimedOut);
|
|
1115
|
+
await failed(stalled, BridgeRconCode.TimedOut);
|
|
1116
|
+
|
|
1117
|
+
expect(fake.commands).toEqual([
|
|
1118
|
+
"stalled",
|
|
1119
|
+
]);
|
|
1120
|
+
} finally {
|
|
1121
|
+
fake.stop();
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test("reconnects and re-authenticates after a timeout", async () => {
|
|
1126
|
+
let served = 0;
|
|
1127
|
+
|
|
1128
|
+
const fake = createFake({
|
|
1129
|
+
command(frame, peer) {
|
|
1130
|
+
served += 1;
|
|
1131
|
+
|
|
1132
|
+
if (served === 1) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
peer.frame({
|
|
1137
|
+
id: frame.id,
|
|
1138
|
+
type: RconResponse.Value,
|
|
1139
|
+
body: `ran ${frame.body}`,
|
|
1140
|
+
});
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
const client = connect(fake, {
|
|
1145
|
+
timeoutMs: 300,
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
try {
|
|
1149
|
+
await failed(client.command("one"), BridgeRconCode.TimedOut);
|
|
1150
|
+
|
|
1151
|
+
expect(await client.command("two")).toBe("ran two");
|
|
1152
|
+
expect(fake.auths).toBe(2);
|
|
1153
|
+
} finally {
|
|
1154
|
+
await client.disconnect();
|
|
1155
|
+
|
|
1156
|
+
fake.stop();
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test("never writes a command whose deadline passed while the client was dialing", async () => {
|
|
1161
|
+
const fake = createFake({
|
|
1162
|
+
auth(frame, peer) {
|
|
1163
|
+
setTimeout(() => {
|
|
1164
|
+
peer.frame({
|
|
1165
|
+
id: frame.id,
|
|
1166
|
+
type: RconResponse.Value,
|
|
1167
|
+
body: "",
|
|
1168
|
+
});
|
|
1169
|
+
peer.frame({
|
|
1170
|
+
id: frame.id,
|
|
1171
|
+
type: RconResponse.Auth,
|
|
1172
|
+
body: "",
|
|
1173
|
+
});
|
|
1174
|
+
}, 300);
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
const client = connect(fake, {
|
|
1179
|
+
timeoutMs: 2000,
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
try {
|
|
1183
|
+
await failed(
|
|
1184
|
+
client.command("status", {
|
|
1185
|
+
timeoutMs: 100,
|
|
1186
|
+
}),
|
|
1187
|
+
BridgeRconCode.TimedOut,
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
await Bun.sleep(500);
|
|
1191
|
+
|
|
1192
|
+
expect(fake.commands).toEqual([]);
|
|
1193
|
+
expect(fake.auths).toBe(1);
|
|
1194
|
+
} finally {
|
|
1195
|
+
await client.disconnect();
|
|
1196
|
+
|
|
1197
|
+
fake.stop();
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
describe("RconClient reconnects", () => {
|
|
1203
|
+
test("reconnects transparently after the server closes between commands", async () => {
|
|
1204
|
+
const fake = createFake();
|
|
1205
|
+
const client = connect(fake);
|
|
1206
|
+
|
|
1207
|
+
try {
|
|
1208
|
+
expect(await client.command("first")).toBe("ran first");
|
|
1209
|
+
|
|
1210
|
+
fake.drop();
|
|
1211
|
+
|
|
1212
|
+
await until(() => !client.connected);
|
|
1213
|
+
|
|
1214
|
+
expect(await client.command("second")).toBe("ran second");
|
|
1215
|
+
expect(fake.auths).toBe(2);
|
|
1216
|
+
} finally {
|
|
1217
|
+
await client.disconnect();
|
|
1218
|
+
|
|
1219
|
+
fake.stop();
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
test("never resends a command the server cut off mid-reply", async () => {
|
|
1224
|
+
const fake = createFake({
|
|
1225
|
+
command(frame, peer) {
|
|
1226
|
+
if (frame.body === "risky") {
|
|
1227
|
+
peer.end();
|
|
1228
|
+
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
peer.frame({
|
|
1233
|
+
id: frame.id,
|
|
1234
|
+
type: RconResponse.Value,
|
|
1235
|
+
body: `ran ${frame.body}`,
|
|
1236
|
+
});
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
const client = connect(fake);
|
|
1241
|
+
|
|
1242
|
+
try {
|
|
1243
|
+
await failed(client.command("risky"), BridgeRconCode.Closed);
|
|
1244
|
+
|
|
1245
|
+
expect(fake.commands.filter((body) => body === "risky")).toHaveLength(1);
|
|
1246
|
+
expect(await client.command("safe")).toBe("ran safe");
|
|
1247
|
+
} finally {
|
|
1248
|
+
await client.disconnect();
|
|
1249
|
+
|
|
1250
|
+
fake.stop();
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test("locks the client when the password stops working on a reconnect", async () => {
|
|
1255
|
+
const fake = createFake();
|
|
1256
|
+
const client = connect(fake);
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
await client.connect();
|
|
1260
|
+
|
|
1261
|
+
fake.password = "rotated";
|
|
1262
|
+
|
|
1263
|
+
fake.drop();
|
|
1264
|
+
|
|
1265
|
+
await until(() => !client.connected);
|
|
1266
|
+
await failed(client.command("first"), BridgeRconCode.AuthFailed);
|
|
1267
|
+
|
|
1268
|
+
expect(fake.auths).toBe(2);
|
|
1269
|
+
|
|
1270
|
+
await failed(client.command("second"), BridgeRconCode.AuthFailed);
|
|
1271
|
+
|
|
1272
|
+
expect(fake.auths).toBe(2);
|
|
1273
|
+
|
|
1274
|
+
fake.password = PASSWORD;
|
|
1275
|
+
|
|
1276
|
+
await client.connect();
|
|
1277
|
+
|
|
1278
|
+
expect(fake.auths).toBe(3);
|
|
1279
|
+
expect(await client.command("third")).toBe("ran third");
|
|
1280
|
+
} finally {
|
|
1281
|
+
await client.disconnect();
|
|
1282
|
+
|
|
1283
|
+
fake.stop();
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
describe("RconClient concurrency", () => {
|
|
1289
|
+
test("runs parallel commands in order with distinct ids", async () => {
|
|
1290
|
+
const fake = createFake();
|
|
1291
|
+
const client = connect(fake);
|
|
1292
|
+
|
|
1293
|
+
try {
|
|
1294
|
+
expect(
|
|
1295
|
+
await Promise.all([
|
|
1296
|
+
client.command("one"),
|
|
1297
|
+
client.command("two"),
|
|
1298
|
+
client.command("three"),
|
|
1299
|
+
]),
|
|
1300
|
+
).toEqual([
|
|
1301
|
+
"ran one",
|
|
1302
|
+
"ran two",
|
|
1303
|
+
"ran three",
|
|
1304
|
+
]);
|
|
1305
|
+
|
|
1306
|
+
expect(new Set(commandIds(fake)).size).toBe(3);
|
|
1307
|
+
} finally {
|
|
1308
|
+
await client.disconnect();
|
|
1309
|
+
|
|
1310
|
+
fake.stop();
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test("keeps a timed-out command from failing the next one", async () => {
|
|
1315
|
+
const fake = createFake({
|
|
1316
|
+
command(frame, peer) {
|
|
1317
|
+
if (frame.body === "slow") {
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
peer.frame({
|
|
1322
|
+
id: frame.id,
|
|
1323
|
+
type: RconResponse.Value,
|
|
1324
|
+
body: `ran ${frame.body}`,
|
|
1325
|
+
});
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
const client = connect(fake, {
|
|
1330
|
+
timeoutMs: 400,
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
try {
|
|
1334
|
+
const slow = client.command("slow");
|
|
1335
|
+
|
|
1336
|
+
const fast = client.command("fast", {
|
|
1337
|
+
timeoutMs: 3000,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
await failed(slow, BridgeRconCode.TimedOut);
|
|
1341
|
+
|
|
1342
|
+
expect(await fast).toBe("ran fast");
|
|
1343
|
+
} finally {
|
|
1344
|
+
await client.disconnect();
|
|
1345
|
+
|
|
1346
|
+
fake.stop();
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("rejects queued commands on disconnect and reconnects afterwards", async () => {
|
|
1351
|
+
const fake = createFake({
|
|
1352
|
+
command(frame, peer) {
|
|
1353
|
+
if (frame.body === "stalled") {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
peer.frame({
|
|
1358
|
+
id: frame.id,
|
|
1359
|
+
type: RconResponse.Value,
|
|
1360
|
+
body: `ran ${frame.body}`,
|
|
1361
|
+
});
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
const client = connect(fake);
|
|
1366
|
+
|
|
1367
|
+
try {
|
|
1368
|
+
const stalled = client.command("stalled");
|
|
1369
|
+
const queued = client.command("queued");
|
|
1370
|
+
|
|
1371
|
+
await until(() => fake.commands.length === 1);
|
|
1372
|
+
await client.disconnect();
|
|
1373
|
+
await failed(stalled, BridgeRconCode.Closed);
|
|
1374
|
+
await failed(queued, BridgeRconCode.Closed);
|
|
1375
|
+
|
|
1376
|
+
expect(fake.commands).toEqual([
|
|
1377
|
+
"stalled",
|
|
1378
|
+
]);
|
|
1379
|
+
expect(await client.command("after")).toBe("ran after");
|
|
1380
|
+
expect(fake.auths).toBe(2);
|
|
1381
|
+
} finally {
|
|
1382
|
+
await client.disconnect();
|
|
1383
|
+
|
|
1384
|
+
fake.stop();
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
describe("RconClient shutdown", () => {
|
|
1390
|
+
test("ends the socket and resolves when the server closes it", async () => {
|
|
1391
|
+
const fake = createFake();
|
|
1392
|
+
const client = connect(fake);
|
|
1393
|
+
|
|
1394
|
+
try {
|
|
1395
|
+
await client.connect();
|
|
1396
|
+
await client.disconnect();
|
|
1397
|
+
await until(() => fake.closes === 1);
|
|
1398
|
+
|
|
1399
|
+
expect(client.connected).toBe(false);
|
|
1400
|
+
} finally {
|
|
1401
|
+
fake.stop();
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
test("is idempotent", async () => {
|
|
1406
|
+
const fake = createFake();
|
|
1407
|
+
const client = connect(fake);
|
|
1408
|
+
|
|
1409
|
+
try {
|
|
1410
|
+
await client.connect();
|
|
1411
|
+
await client.disconnect();
|
|
1412
|
+
await client.disconnect();
|
|
1413
|
+
|
|
1414
|
+
expect(client.connected).toBe(false);
|
|
1415
|
+
} finally {
|
|
1416
|
+
fake.stop();
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
test("resolves when the client never connected", async () => {
|
|
1421
|
+
const fake = createFake();
|
|
1422
|
+
const client = connect(fake);
|
|
1423
|
+
|
|
1424
|
+
try {
|
|
1425
|
+
await client.disconnect();
|
|
1426
|
+
|
|
1427
|
+
expect(fake.auths).toBe(0);
|
|
1428
|
+
} finally {
|
|
1429
|
+
fake.stop();
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("tracks connected across a reconnect and a server close", async () => {
|
|
1434
|
+
const fake = createFake();
|
|
1435
|
+
const client = connect(fake);
|
|
1436
|
+
|
|
1437
|
+
try {
|
|
1438
|
+
await client.connect();
|
|
1439
|
+
|
|
1440
|
+
expect(client.connected).toBe(true);
|
|
1441
|
+
|
|
1442
|
+
await client.disconnect();
|
|
1443
|
+
|
|
1444
|
+
expect(client.connected).toBe(false);
|
|
1445
|
+
|
|
1446
|
+
await client.connect();
|
|
1447
|
+
|
|
1448
|
+
expect(client.connected).toBe(true);
|
|
1449
|
+
|
|
1450
|
+
fake.drop();
|
|
1451
|
+
|
|
1452
|
+
await until(() => !client.connected);
|
|
1453
|
+
} finally {
|
|
1454
|
+
await client.disconnect();
|
|
1455
|
+
|
|
1456
|
+
fake.stop();
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
describe("RconClient secrecy", () => {
|
|
1462
|
+
test("never names the password in an error", async () => {
|
|
1463
|
+
const rejected = createFake();
|
|
1464
|
+
|
|
1465
|
+
rejected.password = "rotated";
|
|
1466
|
+
|
|
1467
|
+
const silent = createFake({
|
|
1468
|
+
auth() {},
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
const cut = createFake({
|
|
1472
|
+
auth(_frame, peer) {
|
|
1473
|
+
peer.end();
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
const unreachable = new RconClient({
|
|
1478
|
+
host: HOST,
|
|
1479
|
+
port: CLOSED_PORT,
|
|
1480
|
+
password: PASSWORD,
|
|
1481
|
+
timeoutMs: 500,
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
try {
|
|
1485
|
+
const errors = [
|
|
1486
|
+
await failed(connect(rejected).connect(), BridgeRconCode.AuthFailed),
|
|
1487
|
+
await failed(
|
|
1488
|
+
connect(silent, {
|
|
1489
|
+
timeoutMs: 250,
|
|
1490
|
+
}).connect(),
|
|
1491
|
+
BridgeRconCode.TimedOut,
|
|
1492
|
+
),
|
|
1493
|
+
await failed(connect(cut).connect(), BridgeRconCode.Closed),
|
|
1494
|
+
await failed(unreachable.connect(), BridgeRconCode.Unreachable),
|
|
1495
|
+
];
|
|
1496
|
+
|
|
1497
|
+
for (const error of errors) {
|
|
1498
|
+
expect(error.message).not.toContain(PASSWORD);
|
|
1499
|
+
}
|
|
1500
|
+
} finally {
|
|
1501
|
+
rejected.stop();
|
|
1502
|
+
silent.stop();
|
|
1503
|
+
cut.stop();
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
test("never names the command text in a timeout error", async () => {
|
|
1508
|
+
const fake = createFake({
|
|
1509
|
+
command() {},
|
|
1510
|
+
probe() {},
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
const client = connect(fake, {
|
|
1514
|
+
timeoutMs: 250,
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
try {
|
|
1518
|
+
const error = await failed(client.command("banid 3 leaker"), BridgeRconCode.TimedOut);
|
|
1519
|
+
|
|
1520
|
+
expect(error.message).not.toContain("leaker");
|
|
1521
|
+
} finally {
|
|
1522
|
+
fake.stop();
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
describe("RconClient socket hygiene", () => {
|
|
1528
|
+
test("leaves no socket open when the connect deadline passes", async () => {
|
|
1529
|
+
const fake = createFake({
|
|
1530
|
+
auth() {},
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
const client = connect(fake, {
|
|
1534
|
+
timeoutMs: 200,
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
try {
|
|
1538
|
+
await failed(client.connect(), BridgeRconCode.TimedOut);
|
|
1539
|
+
await until(() => fake.closes === 1);
|
|
1540
|
+
} finally {
|
|
1541
|
+
fake.stop();
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
});
|