@tikoci/centrs 0.1.0

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 (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,1493 @@
1
+ /**
2
+ * btest session layer — the stateful half that drives the pure codec
3
+ * (`btest.ts`) and the shared EC-SRP5 core (`ec-srp5.ts`) over real sockets.
4
+ *
5
+ * Sequencing is grounded byte-for-byte on `manawenuz/btest-rs`
6
+ * (`src/protocol.rs`, `client.rs`, `server.rs`, `bandwidth.rs`, `ecsrp5.rs`),
7
+ * the MITM-verified reference against RouterOS 7.x:
8
+ *
9
+ * 1. TCP control on port 2000. Server → HELLO `01 00 00 00`.
10
+ * 2. Client → 16-byte command packet.
11
+ * 3. Server → auth response: `01..`(none/OK) / `02`(MD5, legacy/unsupported) /
12
+ * `03`(EC-SRP5) / `00`(failed). EC-SRP5 then runs the 4-message
13
+ * `[len][payload]` exchange and the server sends a final `01 00 00 00`.
14
+ * 4. **UDP only:** the server then writes the 2-byte big-endian server UDP
15
+ * port on the TCP channel; the client's UDP port is `+256`.
16
+ * 5. Data: TCP bulk on the same socket, or UDP datagrams `[seq u32 BE][payload]`
17
+ * on the negotiated ports. Throughput/CPU are exchanged as 12-byte status
18
+ * messages (~1/s) — symmetrically on the TCP control channel for a UDP test;
19
+ * for a TCP test only the *receiving* server emits them (the client never
20
+ * sends TCP status). Senders adapt rate from the peer's reported
21
+ * `bytesReceived`: `newSpeed = bytesReceived * 8 * 3 / 2`.
22
+ *
23
+ * The transport seams (TCP control channel, UDP socket) are injectable so the
24
+ * client↔server matrix is loopback-testable without a router; the CHR
25
+ * integration test (`test/integration/btest.test.ts`) is the real-RouterOS gate.
26
+ *
27
+ * Scope: single TCP data connection + UDP. TCP multi-connection
28
+ * (`connection-count > 1`) negotiates its session token here but the parallel
29
+ * data-stream fan-out is owned by the orchestrator (a follow-up).
30
+ */
31
+
32
+ import { createSocket } from "node:dgram";
33
+ import { connect as netConnect, type Socket } from "node:net";
34
+ import { CentrsError } from "../errors.ts";
35
+ import {
36
+ BTEST_AUTH_EC_SRP5,
37
+ BTEST_HELLO,
38
+ BTEST_PORT,
39
+ BTEST_STATUS_MSG_SIZE,
40
+ BTEST_STATUS_MSG_TYPE,
41
+ BTEST_UDP_PORT_START,
42
+ type BtestAuthKind,
43
+ type BtestCommand,
44
+ type BtestCommandOptions,
45
+ type BtestDirection,
46
+ type BtestProtocol,
47
+ classifyAuthResponse,
48
+ clientUdpPort,
49
+ decodeCommand,
50
+ decodeStatus,
51
+ defaultTxSize,
52
+ encodeAuthFrame,
53
+ encodeAuthOk,
54
+ encodeClientHello,
55
+ encodeCommand,
56
+ encodeConfirmation,
57
+ encodeServerChallenge,
58
+ encodeStatus,
59
+ encodeUdpPacket,
60
+ parseClientHello,
61
+ parseServerChallenge,
62
+ readUdpSequence,
63
+ serverDirectionByte,
64
+ } from "./btest.ts";
65
+ import {
66
+ bytesToBigIntBE,
67
+ ecSrp5ClientConfirm,
68
+ ecSrp5ClientShared,
69
+ ecSrp5Id,
70
+ ecSrp5Keygen,
71
+ ecSrp5ServerConfirm,
72
+ ecSrp5ServerPublicKey,
73
+ ecSrp5ServerShared,
74
+ } from "./ec-srp5.ts";
75
+
76
+ /** Default status-exchange cadence (RouterOS uses ~1s). */
77
+ export const BTEST_STATUS_INTERVAL_MS = 1000;
78
+ /** Auth control words the server can return (`02` = legacy MD5). */
79
+ export const BTEST_AUTH_MD5_BYTE = 0x02;
80
+
81
+ // ── ByteReader: sequential async reader over a chunk stream ───────────────────
82
+
83
+ /**
84
+ * Buffers inbound byte chunks and serves them either as exact-length frames
85
+ * ({@link readExact}, for the control handshake/status) or as the next available
86
+ * run ({@link read}, for bulk TCP data). A channel only ever uses one mode at a
87
+ * time, so the two never race.
88
+ */
89
+ class ByteReader {
90
+ private chunks: Uint8Array[] = [];
91
+ private buffered = 0;
92
+ private exactWaiters: {
93
+ need: number;
94
+ resolve: (bytes: Uint8Array) => void;
95
+ reject: (error: Error) => void;
96
+ }[] = [];
97
+ private anyWaiter:
98
+ | {
99
+ resolve: (bytes: Uint8Array | null) => void;
100
+ reject: (e: Error) => void;
101
+ }
102
+ | undefined;
103
+ private ended = false;
104
+ private failure: Error | undefined;
105
+
106
+ push(chunk: Uint8Array): void {
107
+ if (chunk.length === 0) return;
108
+ this.chunks.push(chunk);
109
+ this.buffered += chunk.length;
110
+ this.drain();
111
+ }
112
+
113
+ end(): void {
114
+ this.ended = true;
115
+ this.drain();
116
+ }
117
+
118
+ fail(error: Error): void {
119
+ this.failure ??= error;
120
+ this.drain();
121
+ }
122
+
123
+ /** Bytes currently buffered (for tests/diagnostics). */
124
+ get available(): number {
125
+ return this.buffered;
126
+ }
127
+
128
+ private take(n: number): Uint8Array {
129
+ const out = new Uint8Array(n);
130
+ let offset = 0;
131
+ while (offset < n) {
132
+ const head = this.chunks[0] as Uint8Array;
133
+ const want = n - offset;
134
+ if (head.length <= want) {
135
+ out.set(head, offset);
136
+ offset += head.length;
137
+ this.chunks.shift();
138
+ } else {
139
+ out.set(head.subarray(0, want), offset);
140
+ this.chunks[0] = head.subarray(want);
141
+ offset += want;
142
+ }
143
+ }
144
+ this.buffered -= n;
145
+ return out;
146
+ }
147
+
148
+ private takeAll(): Uint8Array {
149
+ const out = this.take(this.buffered);
150
+ return out;
151
+ }
152
+
153
+ private drain(): void {
154
+ // Satisfy exact-length frame waiters in order.
155
+ while (this.exactWaiters.length > 0) {
156
+ const waiter = this.exactWaiters[0] as (typeof this.exactWaiters)[number];
157
+ if (this.failure) {
158
+ this.exactWaiters.shift();
159
+ waiter.reject(this.failure);
160
+ continue;
161
+ }
162
+ if (this.buffered >= waiter.need) {
163
+ this.exactWaiters.shift();
164
+ waiter.resolve(this.take(waiter.need));
165
+ continue;
166
+ }
167
+ if (this.ended) {
168
+ this.exactWaiters.shift();
169
+ waiter.reject(
170
+ new CentrsError({
171
+ code: "routeros/btest-protocol",
172
+ summary: `btest control stream ended mid-frame (needed ${waiter.need} more bytes).`,
173
+ remediation: "The peer closed the connection during the handshake.",
174
+ }),
175
+ );
176
+ continue;
177
+ }
178
+ break;
179
+ }
180
+ // Satisfy a pending bulk reader.
181
+ if (this.anyWaiter) {
182
+ if (this.failure) {
183
+ const w = this.anyWaiter;
184
+ this.anyWaiter = undefined;
185
+ w.reject(this.failure);
186
+ } else if (this.buffered > 0) {
187
+ const w = this.anyWaiter;
188
+ this.anyWaiter = undefined;
189
+ w.resolve(this.takeAll());
190
+ } else if (this.ended) {
191
+ const w = this.anyWaiter;
192
+ this.anyWaiter = undefined;
193
+ w.resolve(null);
194
+ }
195
+ }
196
+ }
197
+
198
+ readExact(n: number): Promise<Uint8Array> {
199
+ return new Promise((resolve, reject) => {
200
+ this.exactWaiters.push({ need: n, resolve, reject });
201
+ this.drain();
202
+ });
203
+ }
204
+
205
+ /** Resolve with the next available bytes, or `null` at clean EOF. */
206
+ read(): Promise<Uint8Array | null> {
207
+ return new Promise((resolve, reject) => {
208
+ if (this.anyWaiter) {
209
+ reject(new Error("btest ByteReader: concurrent read() not supported"));
210
+ return;
211
+ }
212
+ this.anyWaiter = { resolve, reject };
213
+ this.drain();
214
+ });
215
+ }
216
+ }
217
+
218
+ // ── Control channel (TCP) ─────────────────────────────────────────────────────
219
+
220
+ /** Bidirectional, framed control channel — the btest TCP/2000 stream. */
221
+ export interface BtestControlChannel {
222
+ /** Read exactly `n` bytes (handshake + status frames). */
223
+ readExact(n: number): Promise<Uint8Array>;
224
+ /** Read the next available bytes (bulk TCP data); `null` at EOF. */
225
+ read(): Promise<Uint8Array | null>;
226
+ /** Write bytes, resolving once flushed past backpressure. */
227
+ write(bytes: Uint8Array): Promise<void>;
228
+ /** Close the underlying transport. */
229
+ close(): void;
230
+ /** Peer address label for envelopes/logs, when known. */
231
+ readonly remoteAddress?: string;
232
+ }
233
+
234
+ /**
235
+ * Wrap a connected `node:net` socket as a {@link BtestControlChannel}. `node:net`
236
+ * is used (rather than the native-API path's `Bun.connect`) because btest pushes
237
+ * bulk bidirectional data: `node:net`'s internal write queue + `drain`
238
+ * backpressure is the right fit for a throughput sender, where the native API
239
+ * only ever writes small command sentences.
240
+ */
241
+ export function channelFromSocket(socket: Socket): BtestControlChannel {
242
+ const reader = new ByteReader();
243
+ let closed = false;
244
+ socket.on("data", (chunk: Buffer) => reader.push(new Uint8Array(chunk)));
245
+ socket.on("end", () => reader.end());
246
+ socket.on("close", () => {
247
+ closed = true;
248
+ reader.end();
249
+ });
250
+ socket.on("error", (error: Error) => {
251
+ closed = true;
252
+ reader.fail(error);
253
+ });
254
+ const remote = socket.remoteAddress;
255
+ return {
256
+ readExact: (n) => reader.readExact(n),
257
+ read: () => reader.read(),
258
+ write: (bytes) =>
259
+ new Promise<void>((resolve, reject) => {
260
+ if (closed) {
261
+ reject(new Error("btest channel is closed"));
262
+ return;
263
+ }
264
+ let settled = false;
265
+ const onClose = (): void =>
266
+ finish(new Error("btest channel closed during write"));
267
+ const finish = (error?: Error): void => {
268
+ if (settled) return;
269
+ settled = true;
270
+ socket.removeListener("close", onClose);
271
+ socket.removeListener("error", onClose);
272
+ socket.removeListener("drain", onDrain);
273
+ if (error) reject(error);
274
+ else resolve();
275
+ };
276
+ const onDrain = (): void => finish();
277
+ // A destroyed socket never emits `drain`, so a write parked on
278
+ // backpressure must also unblock on close/error — otherwise the TX
279
+ // loop (and the whole session) hangs when the peer/abort tears down
280
+ // the connection mid-transfer.
281
+ socket.once("close", onClose);
282
+ socket.once("error", onClose);
283
+ const flushed = socket.write(bytes, (error) => {
284
+ if (error) finish(error);
285
+ });
286
+ if (flushed) finish();
287
+ else socket.once("drain", onDrain);
288
+ }),
289
+ close: () => {
290
+ closed = true;
291
+ try {
292
+ socket.destroy();
293
+ } catch {
294
+ /* already closed */
295
+ }
296
+ },
297
+ ...(remote ? { remoteAddress: remote } : {}),
298
+ };
299
+ }
300
+
301
+ /** Connect a TCP control channel to a btest server. */
302
+ export function connectBtestControl(
303
+ host: string,
304
+ port: number = BTEST_PORT,
305
+ ): Promise<BtestControlChannel> {
306
+ return new Promise((resolve, reject) => {
307
+ const socket = netConnect({ host, port });
308
+ socket.setNoDelay(true);
309
+ const onError = (error: NodeJS.ErrnoException): void => {
310
+ const code = error.code;
311
+ if (code === "ECONNREFUSED") {
312
+ reject(
313
+ new CentrsError({
314
+ code: "transport/connection-refused",
315
+ summary: `No btest server answered on ${host}:${port}.`,
316
+ remediation:
317
+ "Enable /tool/bandwidth-server on the device (or start `centrs btest server`) and check the host/--port.",
318
+ context: { host, port, cause: code },
319
+ cause: error,
320
+ }),
321
+ );
322
+ return;
323
+ }
324
+ reject(
325
+ new CentrsError({
326
+ code: "transport/network",
327
+ summary: `Failed to open the btest control connection to ${host}:${port}.`,
328
+ remediation: "Check the host, --port, and network reachability.",
329
+ context: { host, port, cause: code },
330
+ cause: error,
331
+ }),
332
+ );
333
+ };
334
+ socket.once("error", onError);
335
+ socket.once("connect", () => {
336
+ socket.removeListener("error", onError);
337
+ resolve(channelFromSocket(socket));
338
+ });
339
+ });
340
+ }
341
+
342
+ // ── UDP data socket ───────────────────────────────────────────────────────────
343
+
344
+ /** Datagram socket seam for the UDP data path. */
345
+ export interface BtestUdpSocket {
346
+ /** Bind to `host:port`; resolves with the actually-bound port. */
347
+ bind(port: number, host: string): Promise<number>;
348
+ /** Pin the default destination (so {@link send} needs no address). */
349
+ connect(port: number, host: string): Promise<void>;
350
+ /** Send a datagram; `port`/`host` override the connected default. */
351
+ send(bytes: Uint8Array, port?: number, host?: string): void;
352
+ /** Register the inbound-datagram handler. */
353
+ onMessage(handler: (bytes: Uint8Array) => void): void;
354
+ close(): void;
355
+ }
356
+
357
+ /** Production UDP socket over `node:dgram` (udp4). */
358
+ export function createBtestUdpSocket(): BtestUdpSocket {
359
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
360
+ let handler: ((bytes: Uint8Array) => void) | undefined;
361
+ socket.on("message", (message: Buffer) => handler?.(new Uint8Array(message)));
362
+ return {
363
+ bind: (port, host) =>
364
+ new Promise<number>((resolve, reject) => {
365
+ // Keep a permanent error listener so runtime errors (e.g. ICMP
366
+ // "port unreachable" → ECONNREFUSED when a SLIRP peer is
367
+ // unreachable) don't crash the process as unhandled events.
368
+ // Only reject on errors that arrive before the socket is bound.
369
+ let bound = false;
370
+ socket.on("error", (error: Error): void => {
371
+ if (!bound) reject(error);
372
+ // Post-bind errors are swallowed: the UDP TX loop already
373
+ // handles send failures via try/catch; ICMP-driven errors
374
+ // arriving on the event queue are expected when the peer is
375
+ // behind NAT (e.g. SLIRP) and cannot receive our datagrams.
376
+ });
377
+ socket.bind(port, host, () => {
378
+ bound = true;
379
+ resolve(socket.address().port);
380
+ });
381
+ }),
382
+ connect: (port, host) =>
383
+ new Promise<void>((resolve, reject) => {
384
+ socket.connect(port, host, (error?: Error) =>
385
+ error ? reject(error) : resolve(),
386
+ );
387
+ }),
388
+ send: (bytes, port, host) => {
389
+ if (port === undefined) socket.send(bytes);
390
+ else socket.send(bytes, port, host);
391
+ },
392
+ onMessage: (next) => {
393
+ handler = next;
394
+ },
395
+ close: () => {
396
+ try {
397
+ socket.close();
398
+ } catch {
399
+ /* already closed */
400
+ }
401
+ },
402
+ };
403
+ }
404
+
405
+ // ── Bandwidth accounting + timing (bandwidth.rs) ──────────────────────────────
406
+
407
+ /** Per-interval sample handed to the orchestrator's report/CSV stream. */
408
+ export interface BtestIntervalSample {
409
+ seq: number;
410
+ txBytes: number;
411
+ rxBytes: number;
412
+ lostPackets: number;
413
+ txBps: number;
414
+ rxBps: number;
415
+ localCpu: number;
416
+ remoteCpu: number;
417
+ }
418
+
419
+ export type BtestStopReason =
420
+ | "duration-elapsed"
421
+ | "interrupted"
422
+ | "peer-closed"
423
+ | "transport-error";
424
+
425
+ /**
426
+ * Mutable throughput counters shared between a session's TX/RX/status loops —
427
+ * the TS analog of btest-rs `BandwidthState`. Interval counters are read with a
428
+ * swap-to-zero; cumulative totals only grow.
429
+ */
430
+ export class BandwidthCounters {
431
+ txBytes = 0;
432
+ rxBytes = 0;
433
+ rxPackets = 0;
434
+ rxLost = 0;
435
+ totalTx = 0;
436
+ totalRx = 0;
437
+ totalLost = 0;
438
+ intervals = 0;
439
+ /** Sender's current target rate (bits/sec); 0 = unlimited. */
440
+ txSpeed = 0;
441
+ txSpeedChanged = false;
442
+ remoteCpu = 0;
443
+ running = true;
444
+ private lastSeq: number | undefined;
445
+ private stopResolvers: (() => void)[] = [];
446
+
447
+ addTx(n: number): void {
448
+ this.txBytes += n;
449
+ }
450
+ addRx(n: number): void {
451
+ this.rxBytes += n;
452
+ }
453
+
454
+ /** Account a received UDP packet and its sequence-gap loss. */
455
+ observeUdpSeq(seq: number, byteLength: number): void {
456
+ this.rxBytes += byteLength;
457
+ this.rxPackets += 1;
458
+ if (this.lastSeq !== undefined) {
459
+ const expected = (this.lastSeq + 1) >>> 0;
460
+ if (seq > expected) this.rxLost += seq - expected;
461
+ }
462
+ this.lastSeq = seq;
463
+ }
464
+
465
+ swapTx(): number {
466
+ const v = this.txBytes;
467
+ this.txBytes = 0;
468
+ return v;
469
+ }
470
+ swapRx(): number {
471
+ const v = this.rxBytes;
472
+ this.rxBytes = 0;
473
+ return v;
474
+ }
475
+ swapLost(): number {
476
+ const v = this.rxLost;
477
+ this.rxLost = 0;
478
+ return v;
479
+ }
480
+
481
+ recordInterval(tx: number, rx: number, lost: number): void {
482
+ this.totalTx += tx;
483
+ this.totalRx += rx;
484
+ this.totalLost += lost;
485
+ this.intervals += 1;
486
+ }
487
+
488
+ stop(): void {
489
+ if (!this.running) return;
490
+ this.running = false;
491
+ for (const resolve of this.stopResolvers) resolve();
492
+ this.stopResolvers = [];
493
+ }
494
+
495
+ /** Resolve as soon as {@link stop} is called — lets blocked reads bail out. */
496
+ stopped(): Promise<void> {
497
+ if (!this.running) return Promise.resolve();
498
+ return new Promise((resolve) => this.stopResolvers.push(resolve));
499
+ }
500
+
501
+ summary(): {
502
+ totalTx: number;
503
+ totalRx: number;
504
+ totalLost: number;
505
+ intervals: number;
506
+ } {
507
+ return {
508
+ totalTx: this.totalTx,
509
+ totalRx: this.totalRx,
510
+ totalLost: this.totalLost,
511
+ intervals: this.intervals,
512
+ };
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Inter-packet send interval in milliseconds for a target rate, or `null` for
518
+ * unlimited (speed 0). Mirrors `bandwidth::calc_send_interval`: intervals over
519
+ * 500 ms clamp to 1 s.
520
+ */
521
+ export function calcSendIntervalMs(
522
+ speedBps: number,
523
+ txSize: number,
524
+ ): number | null {
525
+ if (speedBps <= 0) return null;
526
+ const bitsPerPacket = txSize * 8;
527
+ const intervalMs = (1000 * bitsPerPacket) / speedBps;
528
+ if (intervalMs > 500) return 1000;
529
+ return Math.max(intervalMs, 0);
530
+ }
531
+
532
+ /** Speed-feedback rule: the peer's bytes/interval → our target bits/sec. */
533
+ export function speedFeedbackBps(bytesReceived: number): number {
534
+ return Math.min(Math.floor((bytesReceived * 8 * 3) / 2), 0xffffffff);
535
+ }
536
+
537
+ function isStatusMarker(buf: Uint8Array, i: number): boolean {
538
+ return buf[i] === BTEST_STATUS_MSG_TYPE && (buf[i + 1] as number) >= 0x80;
539
+ }
540
+
541
+ // ── Handshake ─────────────────────────────────────────────────────────────────
542
+
543
+ /** What both sides agree on after the control handshake. */
544
+ export interface BtestNegotiated {
545
+ command: BtestCommand;
546
+ authKind: BtestAuthKind;
547
+ username?: string;
548
+ /** UDP only. */
549
+ serverUdpPort?: number;
550
+ clientUdpPort?: number;
551
+ /** TCP multi-connection only. */
552
+ sessionToken?: number;
553
+ }
554
+
555
+ export interface BtestClientHandshakeOptions extends BtestCommandOptions {
556
+ username?: string;
557
+ password?: string;
558
+ /** UDP server port base, for computing the client port locally if needed. */
559
+ }
560
+
561
+ function authFailed(detail: string, cause?: unknown): CentrsError {
562
+ return new CentrsError({
563
+ code: "transport/auth-failed",
564
+ summary: `btest authentication failed: ${detail}`,
565
+ remediation:
566
+ "Check --user/--password against the server's bandwidth-server credentials.",
567
+ ...(cause !== undefined ? { cause } : {}),
568
+ });
569
+ }
570
+
571
+ /** Read a btest `[len][payload]` auth frame off the control channel. */
572
+ async function readAuthFrame(
573
+ channel: BtestControlChannel,
574
+ ): Promise<Uint8Array> {
575
+ const lenByte = await channel.readExact(1);
576
+ const len = lenByte[0] as number;
577
+ return len === 0 ? new Uint8Array(0) : channel.readExact(len);
578
+ }
579
+
580
+ /** Drive the client side of the control handshake. */
581
+ export async function clientHandshake(
582
+ channel: BtestControlChannel,
583
+ options: BtestClientHandshakeOptions,
584
+ ): Promise<BtestNegotiated> {
585
+ await channel.readExact(4); // HELLO (not strictly validated, like btest-rs)
586
+ const command = encodeCommand(options);
587
+ await channel.write(command);
588
+
589
+ const response = await channel.readExact(4);
590
+ const kind = classifyAuthResponse(response);
591
+
592
+ if (kind === "failed") {
593
+ throw authFailed("the server rejected the connection");
594
+ }
595
+ if (kind === "md5") {
596
+ throw authFailed(
597
+ "the server requires legacy pre-6.43 MD5 auth, which centrs does not implement",
598
+ );
599
+ }
600
+ if (kind === "unknown") {
601
+ throw new CentrsError({
602
+ code: "routeros/btest-protocol",
603
+ summary: `Unexpected btest auth response 0x${(response[0] as number).toString(16)}.`,
604
+ remediation: "Confirm the peer is a btest bandwidth server.",
605
+ });
606
+ }
607
+
608
+ let username = options.username;
609
+ if (kind === "ec-srp5") {
610
+ if (!options.username || !options.password) {
611
+ throw authFailed(
612
+ "the server requires EC-SRP5 auth but no --user/--password were given",
613
+ );
614
+ }
615
+ try {
616
+ await runClientEcSrp5(channel, options.username, options.password);
617
+ username = options.username;
618
+ // After EC-SRP5 the server sends a final AUTH_OK.
619
+ const post = await channel.readExact(4);
620
+ if (classifyAuthResponse(post) !== "none") {
621
+ throw authFailed("unexpected response after the EC-SRP5 exchange");
622
+ }
623
+ } catch (error) {
624
+ // A mid-exchange close means the server rejected our proof and hung up —
625
+ // surface that as an auth failure, not a raw protocol/stream error.
626
+ if (
627
+ error instanceof CentrsError &&
628
+ error.code === "transport/auth-failed"
629
+ ) {
630
+ throw error;
631
+ }
632
+ throw authFailed(
633
+ "the server closed the connection during EC-SRP5 (likely bad credentials)",
634
+ error,
635
+ );
636
+ }
637
+ }
638
+
639
+ const negotiated: BtestNegotiated = {
640
+ command: decodeCommand(command),
641
+ authKind: kind,
642
+ };
643
+ if (username !== undefined) negotiated.username = username;
644
+
645
+ // TCP multi-connection: the OK carries a 2-byte session token.
646
+ if (options.protocol === "tcp" && (options.tcpConnectionCount ?? 0) > 0) {
647
+ const token =
648
+ kind === "none"
649
+ ? ((response[1] as number) << 8) | (response[2] as number)
650
+ : 0;
651
+ if (token) negotiated.sessionToken = token;
652
+ }
653
+
654
+ if (options.protocol === "udp") {
655
+ const portBytes = await channel.readExact(2);
656
+ const serverUdpPort =
657
+ ((portBytes[0] as number) << 8) | (portBytes[1] as number);
658
+ negotiated.serverUdpPort = serverUdpPort;
659
+ negotiated.clientUdpPort = clientUdpPort(serverUdpPort);
660
+ }
661
+ return negotiated;
662
+ }
663
+
664
+ async function runClientEcSrp5(
665
+ channel: BtestControlChannel,
666
+ username: string,
667
+ password: string,
668
+ ): Promise<void> {
669
+ const keypair = ecSrp5Keygen();
670
+ await channel.write(encodeClientHello(username, keypair.publicKey));
671
+
672
+ const challenge = parseServerChallenge(await readAuthFrame(channel));
673
+ const validator = ecSrp5Id(username, password, challenge.salt);
674
+ const shared = ecSrp5ClientShared(
675
+ keypair.privateKey,
676
+ challenge.serverPublicKey,
677
+ keypair.publicKey,
678
+ validator,
679
+ );
680
+ const clientCc = ecSrp5ClientConfirm(shared);
681
+ await channel.write(encodeConfirmation(clientCc));
682
+
683
+ const serverCc = await readAuthFrame(channel);
684
+ const expected = ecSrp5ServerConfirm(shared, clientCc);
685
+ if (!timingSafeEqualBytes(serverCc, expected)) {
686
+ throw authFailed("the server confirmation did not verify");
687
+ }
688
+ }
689
+
690
+ export interface BtestServerHandshakeOptions {
691
+ /** Server-perspective command (already read elsewhere) is decoded here. */
692
+ authenticate: boolean;
693
+ username?: string;
694
+ password?: string;
695
+ /** Allocated server UDP data port (UDP tests only). */
696
+ serverUdpPort?: number;
697
+ /** OK response (carries a session token for TCP multi-connection). */
698
+ sessionToken?: number;
699
+ }
700
+
701
+ /**
702
+ * Drive the server side of the control handshake on an accepted connection.
703
+ * Reads the 16-byte command, performs auth (none or EC-SRP5), sends the OK, and
704
+ * (for UDP) writes the server UDP port. Throws `transport/auth-failed` on a bad
705
+ * proof so the caller can log it and drop the connection.
706
+ */
707
+ export async function serverHandshake(
708
+ channel: BtestControlChannel,
709
+ options: BtestServerHandshakeOptions,
710
+ ): Promise<BtestNegotiated> {
711
+ // Fail fast on a misconfigured listener: authenticate=true with no (or empty)
712
+ // credential would otherwise accept an empty-username/empty-password proof,
713
+ // which is a silent foot-gun for a network-exposed surface.
714
+ if (options.authenticate && (!options.username || !options.password)) {
715
+ throw new CentrsError({
716
+ code: "validation/option",
717
+ summary:
718
+ "btest server authentication requires a non-empty username and password.",
719
+ remediation:
720
+ "Pass credentials, or run the server with authenticate=false.",
721
+ });
722
+ }
723
+ await channel.write(BTEST_HELLO);
724
+ const commandBytes = await channel.readExact(16);
725
+ const command = decodeCommand(commandBytes);
726
+
727
+ const okResponse = encodeAuthOk(options.sessionToken ?? 0);
728
+
729
+ let username: string | undefined;
730
+ let authKind: BtestAuthKind = "none";
731
+ if (options.authenticate) {
732
+ authKind = "ec-srp5";
733
+ // Prompt for EC-SRP5 (03 00 00 00) before the framed exchange, then the OK.
734
+ await channel.write(BTEST_AUTH_EC_SRP5);
735
+ username = await runServerEcSrp5(
736
+ channel,
737
+ options.username ?? "",
738
+ options.password ?? "",
739
+ );
740
+ await channel.write(okResponse);
741
+ } else {
742
+ await channel.write(okResponse);
743
+ }
744
+
745
+ const negotiated: BtestNegotiated = { command, authKind };
746
+ if (username !== undefined) negotiated.username = username;
747
+ if (options.sessionToken) negotiated.sessionToken = options.sessionToken;
748
+
749
+ if (command.protocol === "udp") {
750
+ const serverUdpPort = options.serverUdpPort ?? BTEST_UDP_PORT_START;
751
+ await channel.write(
752
+ Uint8Array.of((serverUdpPort >> 8) & 0xff, serverUdpPort & 0xff),
753
+ );
754
+ negotiated.serverUdpPort = serverUdpPort;
755
+ negotiated.clientUdpPort = clientUdpPort(serverUdpPort);
756
+ }
757
+ return negotiated;
758
+ }
759
+
760
+ async function runServerEcSrp5(
761
+ channel: BtestControlChannel,
762
+ expectedUser: string,
763
+ password: string,
764
+ ): Promise<string> {
765
+ const hello = parseClientHello(await readAuthFrame(channel));
766
+ if (hello.username !== expectedUser) {
767
+ throw authFailed(`unknown user "${hello.username}"`);
768
+ }
769
+ const salt = ecSrp5RandomSalt();
770
+ const validatorScalar = bytesToBigIntBE(
771
+ ecSrp5Id(expectedUser, password, salt),
772
+ );
773
+ const keypair = ecSrp5Keygen();
774
+ const serverPublicKey = ecSrp5ServerPublicKey(
775
+ keypair.privateKey,
776
+ validatorScalar,
777
+ );
778
+ await channel.write(encodeServerChallenge(serverPublicKey, salt));
779
+
780
+ const shared = ecSrp5ServerShared(
781
+ keypair.privateKey,
782
+ validatorScalar,
783
+ hello.publicKey,
784
+ serverPublicKey,
785
+ );
786
+ const clientCc = await readAuthFrame(channel);
787
+ const expectedCc = ecSrp5ClientConfirm(shared);
788
+ if (!timingSafeEqualBytes(clientCc, expectedCc)) {
789
+ throw authFailed("the client proof did not verify");
790
+ }
791
+ await channel.write(
792
+ encodeConfirmation(ecSrp5ServerConfirm(shared, clientCc)),
793
+ );
794
+ return hello.username;
795
+ }
796
+
797
+ function ecSrp5RandomSalt(): Uint8Array {
798
+ return crypto.getRandomValues(new Uint8Array(16));
799
+ }
800
+
801
+ function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
802
+ if (a.length !== b.length) return false;
803
+ let diff = 0;
804
+ for (let i = 0; i < a.length; i += 1)
805
+ diff |= (a[i] as number) ^ (b[i] as number);
806
+ return diff === 0;
807
+ }
808
+
809
+ // ── Loop primitives ───────────────────────────────────────────────────────────
810
+
811
+ function sleep(ms: number): Promise<void> {
812
+ return new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
813
+ }
814
+
815
+ /** `readExact(n)`, but resolve `null` if the session stops or the read fails. */
816
+ async function readExactOrStop(
817
+ ctx: RunContext,
818
+ n: number,
819
+ ): Promise<Uint8Array | null> {
820
+ return Promise.race([
821
+ ctx.channel.readExact(n).then(
822
+ (bytes) => bytes,
823
+ () => null,
824
+ ),
825
+ ctx.counters.stopped().then(() => null),
826
+ ]);
827
+ }
828
+
829
+ /** `read()`, but resolve `null` if the session stops or the read fails. */
830
+ async function readOrStop(ctx: RunContext): Promise<Uint8Array | null> {
831
+ return Promise.race([
832
+ ctx.channel.read().then(
833
+ (bytes) => bytes,
834
+ () => null,
835
+ ),
836
+ ctx.counters.stopped().then(() => null),
837
+ ]);
838
+ }
839
+
840
+ /** Resolve when `ms` elapses or the next animation/IO tick (unlimited mode). */
841
+ function pace(ms: number | null): Promise<void> {
842
+ if (ms === null) return new Promise((resolve) => setImmediate(resolve));
843
+ return sleep(ms);
844
+ }
845
+
846
+ // ── Data engines ──────────────────────────────────────────────────────────────
847
+
848
+ interface RunContext {
849
+ channel: BtestControlChannel;
850
+ udp?: BtestUdpSocket;
851
+ command: BtestCommand;
852
+ counters: BandwidthCounters;
853
+ /** Bytes per data packet/write. */
854
+ txSize: number;
855
+ /** Initial sender rate cap (bits/sec); 0 = unlimited. */
856
+ txSpeed: number;
857
+ randomData: boolean;
858
+ statusIntervalMs: number;
859
+ clientUdpPort?: number;
860
+ serverUdpPort?: number;
861
+ /** Peer host for UDP (client: server host; server: client host). */
862
+ udpPeerHost?: string;
863
+ onInterval?: (sample: BtestIntervalSample) => void;
864
+ }
865
+
866
+ /** A side's view of which directions it sends/receives. */
867
+ interface RoleDirections {
868
+ shouldTx: boolean;
869
+ shouldRx: boolean;
870
+ }
871
+
872
+ function clientDirections(command: BtestCommand): RoleDirections {
873
+ // client_tx = dir & RX(1); client_rx = dir & TX(2)
874
+ return {
875
+ shouldTx: command.serverReceives,
876
+ shouldRx: command.serverTransmits,
877
+ };
878
+ }
879
+
880
+ function serverDirections(command: BtestCommand): RoleDirections {
881
+ return {
882
+ shouldTx: command.serverTransmits,
883
+ shouldRx: command.serverReceives,
884
+ };
885
+ }
886
+
887
+ /** Bulk TCP send loop: writes `txSize` zero/random payloads until stopped. */
888
+ async function tcpTxLoop(ctx: RunContext, sendStatus: boolean): Promise<void> {
889
+ await sleep(100);
890
+ const { counters } = ctx;
891
+ const packet = ctx.randomData
892
+ ? crypto.getRandomValues(new Uint8Array(ctx.txSize))
893
+ : new Uint8Array(ctx.txSize);
894
+ let interval = calcSendIntervalMs(counters.txSpeed, ctx.txSize);
895
+ let statusSeq = 0;
896
+ let nextStatus = Date.now() + ctx.statusIntervalMs;
897
+
898
+ while (counters.running) {
899
+ if (sendStatus && Date.now() >= nextStatus) {
900
+ statusSeq += 1;
901
+ const rx = counters.swapRx();
902
+ // `sendStatus` is the server's `both` TCP role: it transmits bulk here
903
+ // *and* receives the client's transmit half. The wire status reports rx
904
+ // (so the client adapts its send rate), but the interval accounting must
905
+ // flush both halves — the bulk tx is in `txBytes` via addTx and would
906
+ // otherwise never reach the session total (a `both` session then showed
907
+ // totalTxBytes=0). A pure TX-only sender uses sendStatus=false and is
908
+ // accounted by reportOnlyLoop, so there is no double-count here.
909
+ const tx = counters.swapTx();
910
+ try {
911
+ await ctx.channel.write(encodeStatus(statusSeq, rx & 0xffffffff, 0));
912
+ } catch {
913
+ counters.stop();
914
+ break;
915
+ }
916
+ counters.recordInterval(tx, rx, 0);
917
+ emitInterval(ctx, statusSeq, tx, rx, 0);
918
+ nextStatus = Date.now() + ctx.statusIntervalMs;
919
+ }
920
+ try {
921
+ await ctx.channel.write(packet);
922
+ } catch {
923
+ counters.stop();
924
+ break;
925
+ }
926
+ counters.addTx(ctx.txSize);
927
+ if (counters.txSpeedChanged) {
928
+ counters.txSpeedChanged = false;
929
+ interval = calcSendIntervalMs(counters.txSpeed, ctx.txSize);
930
+ }
931
+ await pace(interval);
932
+ }
933
+ }
934
+
935
+ /** Bulk TCP receive loop: counts bytes and scans for interleaved status. */
936
+ async function tcpRxLoop(ctx: RunContext): Promise<void> {
937
+ const { counters } = ctx;
938
+ while (counters.running) {
939
+ const chunk = await readOrStop(ctx);
940
+ if (chunk === null) {
941
+ counters.stop();
942
+ break;
943
+ }
944
+ counters.addRx(chunk.length);
945
+ if (chunk.length >= BTEST_STATUS_MSG_SIZE) {
946
+ for (let i = 0; i <= chunk.length - BTEST_STATUS_MSG_SIZE; i += 1) {
947
+ if (isStatusMarker(chunk, i)) {
948
+ counters.remoteCpu = Math.min((chunk[i + 1] as number) & 0x7f, 100);
949
+ break;
950
+ }
951
+ }
952
+ }
953
+ }
954
+ }
955
+
956
+ /** Read server→client status frames (client TX-only TCP) and adapt rate. */
957
+ async function tcpStatusReaderLoop(ctx: RunContext): Promise<void> {
958
+ const { counters } = ctx;
959
+ while (counters.running) {
960
+ const frame = await readExactOrStop(ctx, BTEST_STATUS_MSG_SIZE);
961
+ if (frame === null) break;
962
+ if (frame[0] !== BTEST_STATUS_MSG_TYPE) continue;
963
+ const status = decodeStatus(frame);
964
+ counters.remoteCpu = status.cpuLoad;
965
+ if (status.bytesReceived > 0) {
966
+ counters.txSpeed = speedFeedbackBps(status.bytesReceived);
967
+ counters.txSpeedChanged = true;
968
+ }
969
+ }
970
+ }
971
+
972
+ /** Send server→client status frames (server RX-only TCP). */
973
+ async function tcpStatusSenderLoop(ctx: RunContext): Promise<void> {
974
+ const { counters } = ctx;
975
+ let seq = 0;
976
+ while (counters.running) {
977
+ await sleep(ctx.statusIntervalMs);
978
+ if (!counters.running) break;
979
+ seq += 1;
980
+ const rx = counters.swapRx();
981
+ try {
982
+ await ctx.channel.write(encodeStatus(seq, rx & 0xffffffff, 0));
983
+ } catch {
984
+ counters.stop();
985
+ break;
986
+ }
987
+ counters.recordInterval(0, rx, 0);
988
+ emitInterval(ctx, seq, 0, rx, 0);
989
+ }
990
+ }
991
+
992
+ /** TX-side local status reporting that emits no wire frames (server/client TX). */
993
+ async function reportOnlyLoop(
994
+ ctx: RunContext,
995
+ dirs: RoleDirections,
996
+ ): Promise<void> {
997
+ const { counters } = ctx;
998
+ let seq = 0;
999
+ while (counters.running) {
1000
+ await sleep(ctx.statusIntervalMs);
1001
+ if (!counters.running) break;
1002
+ seq += 1;
1003
+ const tx = dirs.shouldTx ? counters.swapTx() : 0;
1004
+ const rx = dirs.shouldRx ? counters.swapRx() : 0;
1005
+ const lost = dirs.shouldRx ? counters.swapLost() : 0;
1006
+ counters.recordInterval(tx, rx, lost);
1007
+ emitInterval(ctx, seq, tx, rx, lost);
1008
+ }
1009
+ }
1010
+
1011
+ /** UDP send loop: `[seq u32 BE][payload]` datagrams until stopped. */
1012
+ async function udpTxLoop(
1013
+ ctx: RunContext,
1014
+ target?: { port: number; host: string },
1015
+ ): Promise<void> {
1016
+ const udp = ctx.udp as BtestUdpSocket;
1017
+ const { counters } = ctx;
1018
+ let seq = 0;
1019
+ let interval = calcSendIntervalMs(counters.txSpeed, ctx.txSize);
1020
+ // A bandwidth test emits a huge number of datagrams, so a fresh allocation per
1021
+ // packet is pure GC pressure. Rotate through a small ring of pre-allocated
1022
+ // buffers instead, writing only the 4-byte BE seq each send. node:dgram must
1023
+ // not see a buffer mutated while an earlier send is still queued, but pace()
1024
+ // yields the event loop every iteration (sleep, or setImmediate when
1025
+ // unlimited), so a slot's prior send has flushed long before we cycle back to
1026
+ // it. The first allocation also runs encodeUdpPacket's size guard (≥ 4 bytes).
1027
+ const RING = 8;
1028
+ const ring = Array.from({ length: RING }, (_, i) => {
1029
+ const buf =
1030
+ i === 0
1031
+ ? encodeUdpPacket(0, ctx.txSize, ctx.randomData)
1032
+ : new Uint8Array(ctx.txSize);
1033
+ if (i !== 0 && ctx.randomData) crypto.getRandomValues(buf);
1034
+ return {
1035
+ buf,
1036
+ view: new DataView(buf.buffer, buf.byteOffset, buf.byteLength),
1037
+ };
1038
+ });
1039
+ let slot = 0;
1040
+ while (counters.running) {
1041
+ const cell = ring[slot] as (typeof ring)[number];
1042
+ slot = (slot + 1) % RING;
1043
+ cell.view.setUint32(0, seq, false);
1044
+ try {
1045
+ if (target) udp.send(cell.buf, target.port, target.host);
1046
+ else udp.send(cell.buf);
1047
+ } catch {
1048
+ await sleep(1);
1049
+ continue;
1050
+ }
1051
+ seq = (seq + 1) >>> 0;
1052
+ counters.addTx(ctx.txSize);
1053
+ if (counters.txSpeedChanged) {
1054
+ counters.txSpeedChanged = false;
1055
+ interval = calcSendIntervalMs(counters.txSpeed, ctx.txSize);
1056
+ }
1057
+ await pace(interval);
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * UDP receive accounting. Datagrams arrive via {@link BtestUdpSocket.onMessage};
1063
+ * this loop just keeps the task alive until the test stops (the message handler
1064
+ * does the counting + loss accounting).
1065
+ */
1066
+ async function udpRxLoop(ctx: RunContext): Promise<void> {
1067
+ const { counters } = ctx;
1068
+ (ctx.udp as BtestUdpSocket).onMessage((bytes) => {
1069
+ if (bytes.length < 4) return;
1070
+ counters.observeUdpSeq(readUdpSequence(bytes), bytes.length);
1071
+ });
1072
+ while (counters.running) await sleep(ctx.statusIntervalMs);
1073
+ }
1074
+
1075
+ /**
1076
+ * UDP status exchange on the TCP control channel. Both sides run this: an
1077
+ * interval sender emits our status (bytes received this interval) and a
1078
+ * background reader consumes the peer's status, driving speed feedback. This
1079
+ * decoupled model is equivalent to btest-rs's pselect interleave without the
1080
+ * partial-frame hazard of racing a timeout against a framed read.
1081
+ */
1082
+ async function udpStatusExchange(
1083
+ ctx: RunContext,
1084
+ dirs: RoleDirections,
1085
+ ): Promise<void> {
1086
+ const { counters } = ctx;
1087
+
1088
+ const reader = (async () => {
1089
+ while (counters.running) {
1090
+ const frame = await readExactOrStop(ctx, BTEST_STATUS_MSG_SIZE);
1091
+ if (frame === null) {
1092
+ counters.stop();
1093
+ break;
1094
+ }
1095
+ if (frame[0] !== BTEST_STATUS_MSG_TYPE) continue;
1096
+ const status = decodeStatus(frame);
1097
+ counters.remoteCpu = status.cpuLoad;
1098
+ if (status.bytesReceived > 0 && dirs.shouldTx) {
1099
+ counters.txSpeed = speedFeedbackBps(status.bytesReceived);
1100
+ counters.txSpeedChanged = true;
1101
+ }
1102
+ }
1103
+ })();
1104
+
1105
+ let seq = 0;
1106
+ while (counters.running) {
1107
+ await sleep(ctx.statusIntervalMs);
1108
+ if (!counters.running) break;
1109
+ seq += 1;
1110
+ const rx = counters.swapRx();
1111
+ const tx = counters.swapTx();
1112
+ const lost = counters.swapLost();
1113
+ const report = dirs.shouldTx && !dirs.shouldRx ? tx : rx;
1114
+ try {
1115
+ await ctx.channel.write(encodeStatus(seq, report & 0xffffffff, 0));
1116
+ } catch {
1117
+ counters.stop();
1118
+ break;
1119
+ }
1120
+ counters.recordInterval(tx, rx, lost);
1121
+ emitInterval(ctx, seq, tx, rx, lost);
1122
+ }
1123
+ await reader;
1124
+ }
1125
+
1126
+ function emitInterval(
1127
+ ctx: RunContext,
1128
+ seq: number,
1129
+ txBytes: number,
1130
+ rxBytes: number,
1131
+ lostPackets: number,
1132
+ ): void {
1133
+ if (!ctx.onInterval) return;
1134
+ const secs = ctx.statusIntervalMs / 1000;
1135
+ ctx.onInterval({
1136
+ seq,
1137
+ txBytes,
1138
+ rxBytes,
1139
+ lostPackets,
1140
+ txBps: secs > 0 ? (txBytes * 8) / secs : 0,
1141
+ rxBps: secs > 0 ? (rxBytes * 8) / secs : 0,
1142
+ localCpu: 0,
1143
+ remoteCpu: ctx.counters.remoteCpu,
1144
+ });
1145
+ }
1146
+
1147
+ // ── Session drivers ───────────────────────────────────────────────────────────
1148
+
1149
+ export interface BtestClientSessionOptions {
1150
+ host: string;
1151
+ controlPort?: number;
1152
+ protocol: BtestProtocol;
1153
+ direction: BtestDirection;
1154
+ username?: string;
1155
+ password?: string;
1156
+ localTxSpeed?: number;
1157
+ remoteTxSpeed?: number;
1158
+ /** UDP per-packet size; TCP uses its bulk default. */
1159
+ txSize?: number;
1160
+ randomData?: boolean;
1161
+ natMode?: boolean;
1162
+ durationMs?: number;
1163
+ statusIntervalMs?: number;
1164
+ signal?: AbortSignal;
1165
+ onInterval?: (sample: BtestIntervalSample) => void;
1166
+ /** Injection seams (tests). */
1167
+ connect?: (host: string, port: number) => Promise<BtestControlChannel>;
1168
+ createUdpSocket?: () => BtestUdpSocket;
1169
+ }
1170
+
1171
+ export interface BtestRunSummary {
1172
+ protocol: BtestProtocol;
1173
+ direction: BtestDirection;
1174
+ authKind: BtestAuthKind;
1175
+ username?: string;
1176
+ totalTxBytes: number;
1177
+ totalRxBytes: number;
1178
+ totalLostPackets: number;
1179
+ intervals: number;
1180
+ durationMs: number;
1181
+ stopReason: BtestStopReason;
1182
+ serverUdpPort?: number;
1183
+ clientUdpPort?: number;
1184
+ }
1185
+
1186
+ /** Run a full btest **client** session and return its aggregate summary. */
1187
+ export async function runBtestClientSession(
1188
+ options: BtestClientSessionOptions,
1189
+ ): Promise<BtestRunSummary> {
1190
+ const start = Date.now();
1191
+ const controlPort = options.controlPort ?? BTEST_PORT;
1192
+ const connect = options.connect ?? connectBtestControl;
1193
+ const channel = await connect(options.host, controlPort);
1194
+
1195
+ const txSize = options.txSize ?? defaultTxSize(options.protocol);
1196
+ const handshakeOptions: BtestClientHandshakeOptions = {
1197
+ protocol: options.protocol,
1198
+ direction: options.direction,
1199
+ ...(options.randomData !== undefined
1200
+ ? { randomData: options.randomData }
1201
+ : {}),
1202
+ ...(options.txSize !== undefined ? { txSize: options.txSize } : {}),
1203
+ ...(options.localTxSpeed !== undefined
1204
+ ? { localTxSpeed: options.localTxSpeed }
1205
+ : {}),
1206
+ ...(options.remoteTxSpeed !== undefined
1207
+ ? { remoteTxSpeed: options.remoteTxSpeed }
1208
+ : {}),
1209
+ ...(options.username !== undefined ? { username: options.username } : {}),
1210
+ ...(options.password !== undefined ? { password: options.password } : {}),
1211
+ };
1212
+
1213
+ let negotiated: BtestNegotiated;
1214
+ try {
1215
+ negotiated = await clientHandshake(channel, handshakeOptions);
1216
+ } catch (error) {
1217
+ channel.close();
1218
+ throw error;
1219
+ }
1220
+
1221
+ const counters = new BandwidthCounters();
1222
+ counters.txSpeed = options.localTxSpeed ?? 0;
1223
+ const dirs = clientDirections(negotiated.command);
1224
+
1225
+ let udp: BtestUdpSocket | undefined;
1226
+ if (options.protocol === "udp") {
1227
+ udp = (options.createUdpSocket ?? createBtestUdpSocket)();
1228
+ await udp.bind(negotiated.clientUdpPort as number, "0.0.0.0");
1229
+ await udp.connect(negotiated.serverUdpPort as number, options.host);
1230
+ if (options.natMode || dirs.shouldRx) {
1231
+ // Originate a flow so the server's datagrams can return (NAT/SLIRP).
1232
+ udp.send(new Uint8Array(0));
1233
+ }
1234
+ }
1235
+
1236
+ const ctx: RunContext = {
1237
+ channel,
1238
+ ...(udp ? { udp } : {}),
1239
+ command: negotiated.command,
1240
+ counters,
1241
+ txSize,
1242
+ txSpeed: options.localTxSpeed ?? 0,
1243
+ randomData: options.randomData ?? false,
1244
+ statusIntervalMs: options.statusIntervalMs ?? BTEST_STATUS_INTERVAL_MS,
1245
+ ...(negotiated.clientUdpPort !== undefined
1246
+ ? { clientUdpPort: negotiated.clientUdpPort }
1247
+ : {}),
1248
+ ...(negotiated.serverUdpPort !== undefined
1249
+ ? { serverUdpPort: negotiated.serverUdpPort }
1250
+ : {}),
1251
+ udpPeerHost: options.host,
1252
+ ...(options.onInterval ? { onInterval: options.onInterval } : {}),
1253
+ };
1254
+
1255
+ const stopReason = await driveSession(ctx, dirs, "client", {
1256
+ ...(options.durationMs !== undefined
1257
+ ? { durationMs: options.durationMs }
1258
+ : {}),
1259
+ ...(options.signal ? { signal: options.signal } : {}),
1260
+ });
1261
+
1262
+ udp?.close();
1263
+ channel.close();
1264
+
1265
+ const sum = counters.summary();
1266
+ const summary: BtestRunSummary = {
1267
+ protocol: options.protocol,
1268
+ direction: options.direction,
1269
+ authKind: negotiated.authKind,
1270
+ totalTxBytes: sum.totalTx,
1271
+ totalRxBytes: sum.totalRx,
1272
+ totalLostPackets: sum.totalLost,
1273
+ intervals: sum.intervals,
1274
+ durationMs: Date.now() - start,
1275
+ stopReason,
1276
+ };
1277
+ if (negotiated.username !== undefined) summary.username = negotiated.username;
1278
+ if (negotiated.serverUdpPort !== undefined)
1279
+ summary.serverUdpPort = negotiated.serverUdpPort;
1280
+ if (negotiated.clientUdpPort !== undefined)
1281
+ summary.clientUdpPort = negotiated.clientUdpPort;
1282
+ return summary;
1283
+ }
1284
+
1285
+ export interface BtestServerConnectionOptions {
1286
+ authenticate: boolean;
1287
+ username?: string;
1288
+ password?: string;
1289
+ /** Allocated server UDP data port (UDP tests). */
1290
+ serverUdpPort?: number;
1291
+ /** Bind host for the server's UDP data socket. Default `0.0.0.0`. */
1292
+ udpBindHost?: string;
1293
+ statusIntervalMs?: number;
1294
+ signal?: AbortSignal;
1295
+ durationMs?: number;
1296
+ onInterval?: (sample: BtestIntervalSample) => void;
1297
+ createUdpSocket?: () => BtestUdpSocket;
1298
+ }
1299
+
1300
+ export interface BtestSessionResult {
1301
+ negotiated: BtestNegotiated;
1302
+ totalTxBytes: number;
1303
+ totalRxBytes: number;
1304
+ totalLostPackets: number;
1305
+ intervals: number;
1306
+ durationMs: number;
1307
+ stopReason: BtestStopReason;
1308
+ }
1309
+
1310
+ /**
1311
+ * Handle one accepted btest **server** control connection: run the handshake,
1312
+ * then the data/status engine for the negotiated direction, returning the
1313
+ * session's aggregate. The listener/accept loop and multi-session map live in
1314
+ * the orchestrator.
1315
+ */
1316
+ export async function handleBtestServerConnection(
1317
+ channel: BtestControlChannel,
1318
+ options: BtestServerConnectionOptions,
1319
+ ): Promise<BtestSessionResult> {
1320
+ const start = Date.now();
1321
+ // Closing the channel on abort unblocks **any** pending read — including the
1322
+ // handshake, which (unlike the data loops) does not poll a running flag. This
1323
+ // guarantees the session task settles when the server stops, so a peer whose
1324
+ // handshake diverges from ours surfaces as a fast error, not a hang.
1325
+ if (options.signal) {
1326
+ if (options.signal.aborted) channel.close();
1327
+ else
1328
+ options.signal.addEventListener("abort", () => channel.close(), {
1329
+ once: true,
1330
+ });
1331
+ }
1332
+ const handshakeOptions: BtestServerHandshakeOptions = {
1333
+ authenticate: options.authenticate,
1334
+ ...(options.username !== undefined ? { username: options.username } : {}),
1335
+ ...(options.password !== undefined ? { password: options.password } : {}),
1336
+ ...(options.serverUdpPort !== undefined
1337
+ ? { serverUdpPort: options.serverUdpPort }
1338
+ : {}),
1339
+ };
1340
+ const negotiated = await serverHandshake(channel, handshakeOptions);
1341
+
1342
+ const counters = new BandwidthCounters();
1343
+ counters.txSpeed = negotiated.command.remoteTxSpeed;
1344
+ const dirs = serverDirections(negotiated.command);
1345
+
1346
+ let udp: BtestUdpSocket | undefined;
1347
+ if (negotiated.command.protocol === "udp") {
1348
+ udp = (options.createUdpSocket ?? createBtestUdpSocket)();
1349
+ await udp.bind(
1350
+ negotiated.serverUdpPort as number,
1351
+ options.udpBindHost ?? "0.0.0.0",
1352
+ );
1353
+ // The server leaves its UDP socket **unconnected** and addresses the client
1354
+ // with `send_to`: a connected socket only accepts the connected peer, but a
1355
+ // client behind NAT (e.g. a QEMU SLIRP guest) appears from a rewritten
1356
+ // source port, so the server must receive from any source.
1357
+ }
1358
+
1359
+ const ctx: RunContext = {
1360
+ channel,
1361
+ ...(udp ? { udp } : {}),
1362
+ command: negotiated.command,
1363
+ counters,
1364
+ txSize:
1365
+ negotiated.command.protocol === "udp"
1366
+ ? negotiated.command.txSize
1367
+ : defaultTxSize("tcp"),
1368
+ txSpeed: negotiated.command.remoteTxSpeed,
1369
+ randomData: negotiated.command.randomData,
1370
+ statusIntervalMs: options.statusIntervalMs ?? BTEST_STATUS_INTERVAL_MS,
1371
+ ...(negotiated.clientUdpPort !== undefined
1372
+ ? { clientUdpPort: negotiated.clientUdpPort }
1373
+ : {}),
1374
+ ...(negotiated.serverUdpPort !== undefined
1375
+ ? { serverUdpPort: negotiated.serverUdpPort }
1376
+ : {}),
1377
+ ...(channel.remoteAddress ? { udpPeerHost: channel.remoteAddress } : {}),
1378
+ ...(options.onInterval ? { onInterval: options.onInterval } : {}),
1379
+ };
1380
+
1381
+ const stopReason = await driveSession(ctx, dirs, "server", {
1382
+ ...(options.durationMs !== undefined
1383
+ ? { durationMs: options.durationMs }
1384
+ : {}),
1385
+ ...(options.signal ? { signal: options.signal } : {}),
1386
+ });
1387
+
1388
+ udp?.close();
1389
+
1390
+ const sum = counters.summary();
1391
+ return {
1392
+ negotiated,
1393
+ totalTxBytes: sum.totalTx,
1394
+ totalRxBytes: sum.totalRx,
1395
+ totalLostPackets: sum.totalLost,
1396
+ intervals: sum.intervals,
1397
+ durationMs: Date.now() - start,
1398
+ stopReason,
1399
+ };
1400
+ }
1401
+
1402
+ /**
1403
+ * Spin up the right TX/RX/status tasks for the role + direction + protocol,
1404
+ * arm duration/abort, and await completion. Returns why the session stopped.
1405
+ */
1406
+ async function driveSession(
1407
+ ctx: RunContext,
1408
+ dirs: RoleDirections,
1409
+ role: "client" | "server",
1410
+ bounds: { durationMs?: number; signal?: AbortSignal },
1411
+ ): Promise<BtestStopReason> {
1412
+ const { counters } = ctx;
1413
+ let stopReason: BtestStopReason = "peer-closed";
1414
+
1415
+ let durationTimer: ReturnType<typeof setTimeout> | undefined;
1416
+ const onAbort = (): void => {
1417
+ stopReason = "interrupted";
1418
+ counters.stop();
1419
+ };
1420
+ if (bounds.signal) {
1421
+ if (bounds.signal.aborted) onAbort();
1422
+ else bounds.signal.addEventListener("abort", onAbort, { once: true });
1423
+ }
1424
+ if (bounds.durationMs !== undefined) {
1425
+ durationTimer = setTimeout(() => {
1426
+ stopReason = "duration-elapsed";
1427
+ counters.stop();
1428
+ }, bounds.durationMs);
1429
+ }
1430
+
1431
+ const tasks: Promise<void>[] = [];
1432
+ if (ctx.command.protocol === "udp") {
1433
+ // The client `connect()`s its socket (sends with no per-packet address); the
1434
+ // server is unconnected and `send_to`s the client (so it can receive from a
1435
+ // NAT-rewritten source). See the server's UDP setup.
1436
+ const target =
1437
+ role === "server" &&
1438
+ ctx.clientUdpPort !== undefined &&
1439
+ ctx.udpPeerHost !== undefined
1440
+ ? { port: ctx.clientUdpPort, host: ctx.udpPeerHost }
1441
+ : undefined;
1442
+ if (dirs.shouldTx) tasks.push(udpTxLoop(ctx, target));
1443
+ if (dirs.shouldRx) tasks.push(udpRxLoop(ctx));
1444
+ tasks.push(udpStatusExchange(ctx, dirs));
1445
+ } else {
1446
+ // TCP: only the receiving server emits status frames; the client never does.
1447
+ if (dirs.shouldTx && dirs.shouldRx) {
1448
+ tasks.push(tcpTxLoop(ctx, role === "server"));
1449
+ tasks.push(tcpRxLoop(ctx));
1450
+ if (role === "client") tasks.push(reportOnlyLoop(ctx, dirs));
1451
+ } else if (dirs.shouldTx) {
1452
+ tasks.push(tcpTxLoop(ctx, false));
1453
+ if (role === "client") tasks.push(tcpStatusReaderLoop(ctx));
1454
+ tasks.push(reportOnlyLoop(ctx, dirs));
1455
+ } else if (dirs.shouldRx) {
1456
+ tasks.push(tcpRxLoop(ctx));
1457
+ if (role === "server") tasks.push(tcpStatusSenderLoop(ctx));
1458
+ else tasks.push(reportOnlyLoop(ctx, dirs));
1459
+ }
1460
+ }
1461
+
1462
+ try {
1463
+ await Promise.all(tasks);
1464
+ } catch {
1465
+ stopReason = "transport-error";
1466
+ } finally {
1467
+ if (durationTimer) clearTimeout(durationTimer);
1468
+ bounds.signal?.removeEventListener("abort", onAbort);
1469
+ // Flush the final partial interval. The per-interval loops record totals on
1470
+ // a tick cadence (statusIntervalMs) and exit without capturing bytes that
1471
+ // arrived after the last tick — or before the first tick, when a slow/loaded
1472
+ // host delays it past a short run's end. Fold the remaining counter bytes
1473
+ // into the totals (per this role's directions) so a real transfer is never
1474
+ // reported as zero, and `data.reports[]` stays lossless against the totals.
1475
+ // Guarded so a tick-aligned run gains no phantom interval.
1476
+ const tx = dirs.shouldTx ? counters.swapTx() : 0;
1477
+ const rx = dirs.shouldRx ? counters.swapRx() : 0;
1478
+ const lost = dirs.shouldRx ? counters.swapLost() : 0;
1479
+ if (tx > 0 || rx > 0 || lost > 0) {
1480
+ counters.recordInterval(tx, rx, lost);
1481
+ emitInterval(ctx, counters.intervals, tx, rx, lost);
1482
+ }
1483
+ }
1484
+ return stopReason;
1485
+ }
1486
+
1487
+ // Re-export commonly paired codec helpers for orchestrator convenience.
1488
+ export {
1489
+ type BtestCommand,
1490
+ type BtestCommandOptions,
1491
+ encodeAuthFrame,
1492
+ serverDirectionByte,
1493
+ };