@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
package/src/btest.ts ADDED
@@ -0,0 +1,906 @@
1
+ /**
2
+ * `centrs btest` orchestrator: the client and server commands built on the
3
+ * session layer (`src/protocols/btest-session.ts`) and codec
4
+ * (`src/protocols/btest.ts`).
5
+ *
6
+ * btest is centrs's peer-measurement surface — not a RouterOS-shaped command, so
7
+ * there is no `:parse` / `/console/inspect` gate. Its product contract is
8
+ * **option-grammar validation** (reject incoherent flag combinations *before*
9
+ * opening a socket), EC-SRP5 auth, identity resolution, and the canonical
10
+ * envelope. See `commands/btest/README.md` and `docs/CONSTITUTION.md` (Peer
11
+ * measurement / Network listeners).
12
+ *
13
+ * Both commands run over time and stream live frames through `onReport` /
14
+ * `onSession`; the returned envelope is the final, lossless aggregate.
15
+ */
16
+
17
+ import { createServer } from "node:net";
18
+ import type { CentrsEnvelope, EnvelopeMeta } from "./core/envelope.ts";
19
+ import { asCentrsError, CentrsError, serializeCentrsError } from "./errors.ts";
20
+ import {
21
+ BTEST_PORT,
22
+ BTEST_UDP_PORT_START,
23
+ type BtestAuthKind,
24
+ type BtestDirection,
25
+ type BtestProtocol,
26
+ } from "./protocols/btest.ts";
27
+ import {
28
+ type BtestIntervalSample,
29
+ type BtestStopReason,
30
+ channelFromSocket,
31
+ handleBtestServerConnection,
32
+ runBtestClientSession,
33
+ } from "./protocols/btest-session.ts";
34
+ import { resolveAuth, resolveCdb, resolveTarget } from "./resolver/index.ts";
35
+
36
+ export { BTEST_PORT, BTEST_UDP_PORT_START };
37
+
38
+ /** Status-exchange cadence bounds (RouterOS `interval` 20ms..5s). */
39
+ const MIN_INTERVAL_MS = 20;
40
+ const MAX_INTERVAL_MS = 5000;
41
+ /** UDP per-packet size bounds (RouterOS `*-udp-tx-size`). */
42
+ const MIN_UDP_TX_SIZE = 28;
43
+ const MAX_UDP_TX_SIZE = 64000;
44
+ /** Default concurrent-session cap. */
45
+ const DEFAULT_MAX_SESSIONS = 100;
46
+
47
+ export type BtestOutputFormat = "text" | "csv" | "json" | "yaml";
48
+ export const btestOutputFormats: readonly BtestOutputFormat[] = [
49
+ "text",
50
+ "csv",
51
+ "json",
52
+ "yaml",
53
+ ];
54
+
55
+ // ── Option-grammar validation (the product) ───────────────────────────────────
56
+
57
+ function optionError(option: string, summary: string): CentrsError {
58
+ return new CentrsError({
59
+ code: "validation/option",
60
+ summary,
61
+ remediation:
62
+ "Adjust the flag to a value/combination btest accepts (see `centrs btest --help`).",
63
+ context: { option },
64
+ });
65
+ }
66
+
67
+ function requireRange(
68
+ value: number | undefined,
69
+ min: number,
70
+ max: number,
71
+ option: string,
72
+ ): void {
73
+ if (value === undefined) return;
74
+ if (!Number.isInteger(value) || value < min || value > max) {
75
+ throw optionError(
76
+ option,
77
+ `btest ${option} must be an integer in ${min}..${max} (got ${value}).`,
78
+ );
79
+ }
80
+ }
81
+
82
+ // ── Client ────────────────────────────────────────────────────────────────────
83
+
84
+ export interface BtestClientRequest {
85
+ /** `<router>` positional (CDB identity / target / host). */
86
+ targetInput?: string;
87
+ host?: string;
88
+ protocol?: BtestProtocol;
89
+ direction?: BtestDirection;
90
+ /** Control port; default 2000. */
91
+ controlPort?: number;
92
+ username?: string;
93
+ password?: string;
94
+ durationMs?: number;
95
+ intervalMs?: number;
96
+ connectionCount?: number;
97
+ localUdpTxSize?: number;
98
+ remoteUdpTxSize?: number;
99
+ localTxSpeed?: number;
100
+ remoteTxSpeed?: number;
101
+ randomData?: boolean;
102
+ natMode?: boolean;
103
+ cdbFile?: string;
104
+ cdbPassword?: string;
105
+ env?: Record<string, string | undefined>;
106
+ signal?: AbortSignal;
107
+ /** Live per-interval frame callback (text/csv streaming). */
108
+ onReport?: (record: BtestReportRecord) => void;
109
+ /** Injection seam (tests). */
110
+ runSession?: typeof runBtestClientSession;
111
+ }
112
+
113
+ /** One per-interval client report, lossless against the live stream. */
114
+ export interface BtestReportRecord {
115
+ seq: number;
116
+ direction: BtestDirection;
117
+ protocol: BtestProtocol;
118
+ txBps: number;
119
+ rxBps: number;
120
+ lostPackets: number;
121
+ txBytes: number;
122
+ rxBytes: number;
123
+ }
124
+
125
+ export interface BtestClientData {
126
+ protocol: BtestProtocol;
127
+ direction: BtestDirection;
128
+ authKind: BtestAuthKind;
129
+ user?: string;
130
+ reports: readonly BtestReportRecord[];
131
+ totalTxBytes: number;
132
+ totalRxBytes: number;
133
+ totalLostPackets: number;
134
+ intervals: number;
135
+ durationMs: number;
136
+ txTotalAvgBps: number;
137
+ rxTotalAvgBps: number;
138
+ stopReason: BtestStopReason;
139
+ serverUdpPort?: number;
140
+ clientUdpPort?: number;
141
+ }
142
+
143
+ export interface BtestClientOperationMeta {
144
+ command: "btest";
145
+ mode: "client";
146
+ protocol: BtestProtocol;
147
+ direction: BtestDirection;
148
+ controlPort: number;
149
+ }
150
+
151
+ export type BtestClientEnvelope = CentrsEnvelope<
152
+ BtestClientData,
153
+ BtestClientOperationMeta
154
+ >;
155
+
156
+ function validateClientOptions(request: BtestClientRequest): void {
157
+ const protocol = request.protocol ?? "udp";
158
+ if (request.connectionCount !== undefined) {
159
+ if (protocol !== "tcp") {
160
+ throw optionError(
161
+ "connection-count",
162
+ "btest connection-count applies only to --protocol tcp.",
163
+ );
164
+ }
165
+ requireRange(request.connectionCount, 1, 255, "connection-count");
166
+ }
167
+ for (const [value, option] of [
168
+ [request.localUdpTxSize, "local-udp-tx-size"],
169
+ [request.remoteUdpTxSize, "remote-udp-tx-size"],
170
+ ] as const) {
171
+ if (value !== undefined) {
172
+ if (protocol !== "udp") {
173
+ throw optionError(
174
+ option,
175
+ `btest ${option} applies only to --protocol udp.`,
176
+ );
177
+ }
178
+ requireRange(value, MIN_UDP_TX_SIZE, MAX_UDP_TX_SIZE, option);
179
+ }
180
+ }
181
+ requireRange(
182
+ request.intervalMs,
183
+ MIN_INTERVAL_MS,
184
+ MAX_INTERVAL_MS,
185
+ "interval",
186
+ );
187
+ }
188
+
189
+ /** Per-protocol per-packet size from the local/remote UDP size flags. */
190
+ function clientTxSize(request: BtestClientRequest): number | undefined {
191
+ if ((request.protocol ?? "udp") !== "udp") return undefined;
192
+ const direction = request.direction ?? "receive";
193
+ // The 16-byte command carries a single tx_size; pick the one for the active
194
+ // direction (local for transmit/both, remote for a pure download).
195
+ const size =
196
+ direction === "receive"
197
+ ? request.remoteUdpTxSize
198
+ : (request.localUdpTxSize ?? request.remoteUdpTxSize);
199
+ return size;
200
+ }
201
+
202
+ /** Run a btest **client** test and return the aggregate summary envelope. */
203
+ export async function btestClient(
204
+ request: BtestClientRequest,
205
+ ): Promise<BtestClientEnvelope> {
206
+ const env = request.env ?? Bun.env;
207
+ const protocol = request.protocol ?? "udp";
208
+ const direction = request.direction ?? "receive";
209
+ const controlPort = request.controlPort ?? BTEST_PORT;
210
+ const operation: BtestClientOperationMeta = {
211
+ command: "btest",
212
+ mode: "client",
213
+ protocol,
214
+ direction,
215
+ controlPort,
216
+ };
217
+
218
+ try {
219
+ validateClientOptions(request);
220
+
221
+ const cdbResolution = await resolveCdb(
222
+ {
223
+ targetInput: request.targetInput,
224
+ cdbFile: request.cdbFile,
225
+ cdbPassword: request.cdbPassword,
226
+ },
227
+ env,
228
+ );
229
+ // btest is L3 to a control port; reuse the REST host-resolution path purely
230
+ // to derive the host/identity (the resolved port/scheme are unused — the
231
+ // control port is its own flag).
232
+ const target = resolveTarget(
233
+ { targetInput: request.targetInput, host: request.host },
234
+ env,
235
+ "rest-api",
236
+ cdbResolution,
237
+ );
238
+ const auth = resolveAuth(
239
+ { username: request.username, password: request.password },
240
+ env,
241
+ cdbResolution,
242
+ );
243
+
244
+ const reports: BtestReportRecord[] = [];
245
+ const onInterval = (sample: BtestIntervalSample): void => {
246
+ const record: BtestReportRecord = {
247
+ seq: sample.seq,
248
+ direction,
249
+ protocol,
250
+ txBps: sample.txBps,
251
+ rxBps: sample.rxBps,
252
+ lostPackets: sample.lostPackets,
253
+ txBytes: sample.txBytes,
254
+ rxBytes: sample.rxBytes,
255
+ };
256
+ reports.push(record);
257
+ request.onReport?.(record);
258
+ };
259
+
260
+ const runSession = request.runSession ?? runBtestClientSession;
261
+ const txSize = clientTxSize(request);
262
+ const summary = await runSession({
263
+ host: target.host,
264
+ controlPort,
265
+ protocol,
266
+ direction,
267
+ ...(auth.username !== undefined ? { username: auth.username } : {}),
268
+ ...(auth.passwordProvided ? { password: auth.password } : {}),
269
+ ...(request.durationMs !== undefined
270
+ ? { durationMs: request.durationMs }
271
+ : {}),
272
+ ...(request.intervalMs !== undefined
273
+ ? { statusIntervalMs: request.intervalMs }
274
+ : {}),
275
+ ...(txSize !== undefined ? { txSize } : {}),
276
+ ...(request.localTxSpeed !== undefined
277
+ ? { localTxSpeed: request.localTxSpeed }
278
+ : {}),
279
+ ...(request.remoteTxSpeed !== undefined
280
+ ? { remoteTxSpeed: request.remoteTxSpeed }
281
+ : {}),
282
+ ...(request.randomData !== undefined
283
+ ? { randomData: request.randomData }
284
+ : {}),
285
+ ...(request.natMode !== undefined ? { natMode: request.natMode } : {}),
286
+ ...(request.signal ? { signal: request.signal } : {}),
287
+ onInterval,
288
+ });
289
+
290
+ const secs = summary.durationMs / 1000;
291
+ const data: BtestClientData = {
292
+ protocol: summary.protocol,
293
+ direction: summary.direction,
294
+ authKind: summary.authKind,
295
+ reports,
296
+ totalTxBytes: summary.totalTxBytes,
297
+ totalRxBytes: summary.totalRxBytes,
298
+ totalLostPackets: summary.totalLostPackets,
299
+ intervals: summary.intervals,
300
+ durationMs: summary.durationMs,
301
+ txTotalAvgBps: secs > 0 ? (summary.totalTxBytes * 8) / secs : 0,
302
+ rxTotalAvgBps: secs > 0 ? (summary.totalRxBytes * 8) / secs : 0,
303
+ stopReason: summary.stopReason,
304
+ };
305
+ if (summary.username !== undefined) data.user = summary.username;
306
+ if (summary.serverUdpPort !== undefined)
307
+ data.serverUdpPort = summary.serverUdpPort;
308
+ if (summary.clientUdpPort !== undefined)
309
+ data.clientUdpPort = summary.clientUdpPort;
310
+
311
+ return {
312
+ ok: true,
313
+ data,
314
+ warnings: [],
315
+ tips: [],
316
+ meta: clientMeta(target.host, controlPort, operation, auth.username),
317
+ };
318
+ } catch (error) {
319
+ return buildClientErrorEnvelope(request, operation, error);
320
+ }
321
+ }
322
+
323
+ function clientMeta(
324
+ host: string,
325
+ port: number,
326
+ operation: BtestClientOperationMeta,
327
+ user?: string,
328
+ ): EnvelopeMeta<BtestClientOperationMeta> {
329
+ return {
330
+ target: {
331
+ host,
332
+ port,
333
+ ...(user !== undefined ? { user } : {}),
334
+ },
335
+ via: "btest",
336
+ settings: {},
337
+ operation,
338
+ };
339
+ }
340
+
341
+ function buildClientErrorEnvelope(
342
+ request: BtestClientRequest,
343
+ operation: BtestClientOperationMeta,
344
+ error: unknown,
345
+ ): BtestClientEnvelope {
346
+ const centrsError = asCentrsError(error, {
347
+ code: "routeros/btest-protocol",
348
+ summary: error instanceof Error ? error.message : String(error),
349
+ remediation:
350
+ "Re-run `centrs btest client`; check the host, --port, and credentials.",
351
+ });
352
+ return {
353
+ ok: false,
354
+ error: serializeCentrsError(centrsError),
355
+ warnings: [],
356
+ tips: [],
357
+ meta: {
358
+ target: {
359
+ ...(request.host !== undefined ? { host: request.host } : {}),
360
+ ...(request.targetInput !== undefined
361
+ ? { input: request.targetInput }
362
+ : {}),
363
+ port: operation.controlPort,
364
+ },
365
+ via: "btest",
366
+ settings: {},
367
+ operation,
368
+ },
369
+ };
370
+ }
371
+
372
+ // ── Server ────────────────────────────────────────────────────────────────────
373
+
374
+ export interface BtestServerRequest {
375
+ bind?: string;
376
+ port?: number;
377
+ authenticate?: boolean;
378
+ username?: string;
379
+ password?: string;
380
+ allocateUdpPortsFrom?: number;
381
+ maxSessions?: number;
382
+ intervalMs?: number;
383
+ durationMs?: number;
384
+ env?: Record<string, string | undefined>;
385
+ signal?: AbortSignal;
386
+ /** Called once with the bound control port (test hook). */
387
+ onBound?: (port: number) => void;
388
+ /** Live per-session-event callback (text/csv streaming). */
389
+ onSession?: (record: BtestSessionRecord) => void;
390
+ }
391
+
392
+ export interface BtestSessionRecord {
393
+ client: string;
394
+ protocol: BtestProtocol;
395
+ /** Server-perspective direction (matches `/tool/bandwidth-server session`). */
396
+ direction: BtestDirection;
397
+ user: string;
398
+ authKind: BtestAuthKind;
399
+ connectionCount: number;
400
+ totalTxBytes: number;
401
+ totalRxBytes: number;
402
+ totalLostPackets: number;
403
+ intervals: number;
404
+ durationMs: number;
405
+ txAvgBps: number;
406
+ rxAvgBps: number;
407
+ }
408
+
409
+ export interface BtestServerData {
410
+ bind: string;
411
+ port: number;
412
+ authenticate: boolean;
413
+ maxSessions: number;
414
+ sessions: readonly BtestSessionRecord[];
415
+ stopReason: BtestStopReason;
416
+ }
417
+
418
+ export interface BtestServerOperationMeta {
419
+ command: "btest";
420
+ mode: "server";
421
+ bind: string;
422
+ port: number;
423
+ }
424
+
425
+ export type BtestServerEnvelope = CentrsEnvelope<
426
+ BtestServerData,
427
+ BtestServerOperationMeta
428
+ >;
429
+
430
+ function validateServerOptions(request: BtestServerRequest): void {
431
+ requireRange(request.maxSessions, 1, 1000, "max-sessions");
432
+ requireRange(
433
+ request.intervalMs,
434
+ MIN_INTERVAL_MS,
435
+ MAX_INTERVAL_MS,
436
+ "interval",
437
+ );
438
+ // 0 = OS-assigned ephemeral control port (tests / dynamic binding).
439
+ requireRange(request.port, 0, 65535, "port");
440
+ requireRange(
441
+ request.allocateUdpPortsFrom,
442
+ 1,
443
+ 65535,
444
+ "allocate-udp-ports-from",
445
+ );
446
+ // The UDP data-port window [allocateFrom, allocateFrom + maxSessions) must fit
447
+ // under 65535, or later sessions would be assigned an out-of-range port at
448
+ // runtime — reject the incoherent combination up front.
449
+ const allocFrom = request.allocateUdpPortsFrom ?? BTEST_UDP_PORT_START;
450
+ const sessionCap = request.maxSessions ?? DEFAULT_MAX_SESSIONS;
451
+ if (allocFrom + sessionCap - 1 > 65535) {
452
+ throw optionError(
453
+ "allocate-udp-ports-from",
454
+ `btest allocate-udp-ports-from (${allocFrom}) + max-sessions (${sessionCap}) would run past UDP port 65535; lower one of them.`,
455
+ );
456
+ }
457
+ }
458
+
459
+ function serverDirectionLabel(
460
+ serverTx: boolean,
461
+ serverRx: boolean,
462
+ ): BtestDirection {
463
+ if (serverTx && serverRx) return "both";
464
+ return serverTx ? "transmit" : "receive";
465
+ }
466
+
467
+ /** Run the btest **server** listener until `durationMs` elapses or aborted. */
468
+ export async function btestServer(
469
+ request: BtestServerRequest,
470
+ ): Promise<BtestServerEnvelope> {
471
+ const bind = request.bind ?? "127.0.0.1";
472
+ const port = request.port ?? BTEST_PORT;
473
+ const authenticate = request.authenticate ?? true;
474
+ const maxSessions = request.maxSessions ?? DEFAULT_MAX_SESSIONS;
475
+ const allocateFrom = request.allocateUdpPortsFrom ?? BTEST_UDP_PORT_START;
476
+ // Resolve the single accepted credential through the same path as the client
477
+ // (flags first, then CENTRS_USERNAME / CENTRS_PASSWORD). No CDB here — the
478
+ // server has no target device.
479
+ const auth = resolveAuth(
480
+ { username: request.username, password: request.password },
481
+ request.env ?? Bun.env,
482
+ );
483
+ const operation: BtestServerOperationMeta = {
484
+ command: "btest",
485
+ mode: "server",
486
+ bind,
487
+ port,
488
+ };
489
+
490
+ try {
491
+ validateServerOptions(request);
492
+ // authenticate=true (the default) without a credential would bind a server
493
+ // that rejects every client — fail fast instead.
494
+ if (
495
+ authenticate &&
496
+ (auth.username === undefined || !auth.passwordProvided)
497
+ ) {
498
+ throw optionError(
499
+ "authenticate",
500
+ "btest server authenticate=true needs --user and --password (or CENTRS_USERNAME / CENTRS_PASSWORD); pass --no-authenticate for an open server.",
501
+ );
502
+ }
503
+ } catch (error) {
504
+ return buildServerErrorEnvelope(operation, error);
505
+ }
506
+
507
+ const sessions: BtestSessionRecord[] = [];
508
+ const warnings: { code: string; message: string }[] = [];
509
+ const sessionTasks: Promise<void>[] = [];
510
+ const abort = new AbortController();
511
+ let active = 0;
512
+ let udpOffset = 0;
513
+ let stopReason: BtestStopReason = "interrupted";
514
+
515
+ const externalAbort = (): void => {
516
+ stopReason = "interrupted";
517
+ abort.abort();
518
+ };
519
+ if (request.signal) {
520
+ if (request.signal.aborted) externalAbort();
521
+ else
522
+ request.signal.addEventListener("abort", externalAbort, { once: true });
523
+ }
524
+
525
+ const server = createServer((socket) => {
526
+ const channel = channelFromSocket(socket);
527
+ const client = socket.remoteAddress ?? "unknown";
528
+ if (active >= maxSessions) {
529
+ warnings.push({
530
+ code: "routeros/btest-too-many-sessions",
531
+ message: `Refused ${client}: max-sessions (${maxSessions}) reached.`,
532
+ });
533
+ channel.close();
534
+ return;
535
+ }
536
+ active += 1;
537
+ // Wrap within the validated [allocateFrom, allocateFrom + maxSessions)
538
+ // window so a long-lived server never walks the monotonic counter past
539
+ // 65535; at most `maxSessions` ports are ever live at once.
540
+ const serverUdpPort = allocateFrom + (udpOffset % maxSessions);
541
+ udpOffset += 1;
542
+ sessionTasks.push(
543
+ (async () => {
544
+ try {
545
+ const result = await handleBtestServerConnection(channel, {
546
+ authenticate,
547
+ // Only the resolved credential, and only when auth is on (never
548
+ // leak creds into an unauthenticated handshake).
549
+ ...(authenticate && auth.username !== undefined
550
+ ? { username: auth.username }
551
+ : {}),
552
+ ...(authenticate && auth.passwordProvided
553
+ ? { password: auth.password }
554
+ : {}),
555
+ serverUdpPort,
556
+ udpBindHost: bind === "127.0.0.1" ? "127.0.0.1" : "0.0.0.0",
557
+ ...(request.intervalMs !== undefined
558
+ ? { statusIntervalMs: request.intervalMs }
559
+ : {}),
560
+ signal: abort.signal,
561
+ });
562
+ const cmd = result.negotiated.command;
563
+ const secs = result.durationMs / 1000;
564
+ const record: BtestSessionRecord = {
565
+ client,
566
+ protocol: cmd.protocol,
567
+ direction: serverDirectionLabel(
568
+ cmd.serverTransmits,
569
+ cmd.serverReceives,
570
+ ),
571
+ user: result.negotiated.username ?? "",
572
+ authKind: result.negotiated.authKind,
573
+ connectionCount: cmd.tcpConnectionCount,
574
+ totalTxBytes: result.totalTxBytes,
575
+ totalRxBytes: result.totalRxBytes,
576
+ totalLostPackets: result.totalLostPackets,
577
+ intervals: result.intervals,
578
+ durationMs: result.durationMs,
579
+ txAvgBps: secs > 0 ? (result.totalTxBytes * 8) / secs : 0,
580
+ rxAvgBps: secs > 0 ? (result.totalRxBytes * 8) / secs : 0,
581
+ };
582
+ sessions.push(record);
583
+ request.onSession?.(record);
584
+ } catch (error) {
585
+ // Auth failures / malformed peers keep the server alive — record the
586
+ // anomaly and drop the connection (mirrors RouterOS).
587
+ const code =
588
+ error instanceof CentrsError
589
+ ? error.code
590
+ : "routeros/btest-protocol";
591
+ warnings.push({
592
+ code,
593
+ message: `Session from ${client} failed: ${
594
+ error instanceof Error ? error.message : String(error)
595
+ }`,
596
+ });
597
+ } finally {
598
+ active -= 1;
599
+ channel.close();
600
+ }
601
+ })(),
602
+ );
603
+ });
604
+
605
+ let boundPort = port;
606
+ try {
607
+ await new Promise<void>((resolve, reject) => {
608
+ server.once("error", reject);
609
+ server.listen(port, bind, () => {
610
+ boundPort = (server.address() as { port: number }).port;
611
+ operation.port = boundPort;
612
+ request.onBound?.(boundPort);
613
+ resolve();
614
+ });
615
+ });
616
+ } catch (error) {
617
+ return buildServerErrorEnvelope(operation, listenError(error, bind, port));
618
+ }
619
+
620
+ await new Promise<void>((resolve) => {
621
+ let timer: ReturnType<typeof setTimeout> | undefined;
622
+ if (request.durationMs !== undefined) {
623
+ timer = setTimeout(() => {
624
+ stopReason = "duration-elapsed";
625
+ resolve();
626
+ }, request.durationMs);
627
+ }
628
+ abort.signal.addEventListener(
629
+ "abort",
630
+ () => {
631
+ if (timer) clearTimeout(timer);
632
+ resolve();
633
+ },
634
+ { once: true },
635
+ );
636
+ });
637
+
638
+ server.close();
639
+ abort.abort(); // stop in-flight sessions
640
+ await Promise.allSettled(sessionTasks);
641
+ if (request.signal)
642
+ request.signal.removeEventListener("abort", externalAbort);
643
+
644
+ return {
645
+ ok: true,
646
+ data: {
647
+ bind,
648
+ port: boundPort,
649
+ authenticate,
650
+ maxSessions,
651
+ sessions,
652
+ stopReason,
653
+ },
654
+ warnings: warnings.map((w) => ({ ...w })),
655
+ tips: [],
656
+ meta: { target: {}, via: "btest", settings: {}, operation },
657
+ };
658
+ }
659
+
660
+ function listenError(error: unknown, bind: string, port: number): CentrsError {
661
+ const code =
662
+ error && typeof error === "object" && "code" in error
663
+ ? String((error as { code: unknown }).code)
664
+ : undefined;
665
+ if (code === "EADDRINUSE") {
666
+ // Local bind failure — not a remote peer refusing us, so `transport/network`
667
+ // rather than `transport/connection-refused`.
668
+ return new CentrsError({
669
+ code: "transport/network",
670
+ summary: `TCP ${bind}:${port} is already in use; cannot bind the btest server.`,
671
+ remediation:
672
+ "Stop the process holding the port (often another bandwidth server), or pass a different --port.",
673
+ context: { bind, port, cause: code },
674
+ cause: error,
675
+ });
676
+ }
677
+ return asCentrsError(error, {
678
+ code: "transport/network",
679
+ summary: `Failed to bind the btest server on ${bind}:${port}.`,
680
+ remediation:
681
+ "Check --bind/--port and that TCP listeners are permitted here.",
682
+ context: { bind, port, cause: code },
683
+ });
684
+ }
685
+
686
+ function buildServerErrorEnvelope(
687
+ operation: BtestServerOperationMeta,
688
+ error: unknown,
689
+ ): BtestServerEnvelope {
690
+ const centrsError = asCentrsError(error, {
691
+ code: "routeros/btest-protocol",
692
+ summary: error instanceof Error ? error.message : String(error),
693
+ remediation:
694
+ "Re-run `centrs btest server`; check --bind/--port and credentials.",
695
+ });
696
+ return {
697
+ ok: false,
698
+ error: serializeCentrsError(centrsError),
699
+ warnings: [],
700
+ tips: [],
701
+ meta: { target: {}, via: "btest", settings: {}, operation },
702
+ };
703
+ }
704
+
705
+ // ── Output rendering ──────────────────────────────────────────────────────────
706
+
707
+ /**
708
+ * CSV header for the client interval stream. Columns track the row emitted by
709
+ * {@link btestClientCsvRow} 1:1 — `seq` is the status sequence number and
710
+ * `tx_bytes`/`rx_bytes` are the per-interval byte counts (not packet sizes).
711
+ */
712
+ export const BTEST_CLIENT_CSV_HEADER =
713
+ "seq,direction,protocol,tx_bps,rx_bps,lost_packets,tx_bytes,rx_bytes";
714
+ /**
715
+ * CSV header for the server session stream. `duration_ms` is the session
716
+ * lifetime, `tx_bps`/`rx_bps` are its average throughput, and `lost_packets` is
717
+ * the session's UDP loss total (0 for TCP).
718
+ */
719
+ export const BTEST_SERVER_CSV_HEADER =
720
+ "duration_ms,event,client,protocol,direction,user,tx_bps,rx_bps,lost_packets";
721
+
722
+ /**
723
+ * RFC 4180 field escape: wrap in double quotes (doubling any internal quote)
724
+ * when the value carries a comma, quote, or newline. Keeps string fields like
725
+ * `user` and `client` (e.g. an IPv6 address) from breaking the column layout.
726
+ */
727
+ function escapeCsvField(value: string): string {
728
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
729
+ }
730
+
731
+ export function btestClientCsvRow(record: BtestReportRecord): string {
732
+ return [
733
+ record.seq,
734
+ escapeCsvField(record.direction),
735
+ escapeCsvField(record.protocol),
736
+ Math.round(record.txBps),
737
+ Math.round(record.rxBps),
738
+ record.lostPackets,
739
+ record.txBytes,
740
+ record.rxBytes,
741
+ ].join(",");
742
+ }
743
+
744
+ export function btestServerCsvRow(record: BtestSessionRecord): string {
745
+ return [
746
+ record.durationMs,
747
+ "session",
748
+ escapeCsvField(record.client),
749
+ escapeCsvField(record.protocol),
750
+ escapeCsvField(record.direction),
751
+ escapeCsvField(record.user),
752
+ Math.round(record.txAvgBps),
753
+ Math.round(record.rxAvgBps),
754
+ record.totalLostPackets,
755
+ ].join(",");
756
+ }
757
+
758
+ function formatBps(bps: number): string {
759
+ if (bps >= 1e9) return `${(bps / 1e9).toFixed(2)} Gbps`;
760
+ if (bps >= 1e6) return `${(bps / 1e6).toFixed(2)} Mbps`;
761
+ if (bps >= 1e3) return `${(bps / 1e3).toFixed(2)} Kbps`;
762
+ return `${Math.round(bps)} bps`;
763
+ }
764
+
765
+ export function formatBtestReportText(record: BtestReportRecord): string {
766
+ const parts = [`[${String(record.seq).padStart(3)}]`];
767
+ if (record.direction !== "receive")
768
+ parts.push(`tx ${formatBps(record.txBps)}`);
769
+ if (record.direction !== "transmit")
770
+ parts.push(`rx ${formatBps(record.rxBps)}`);
771
+ if (record.lostPackets > 0) parts.push(`lost ${record.lostPackets}`);
772
+ return parts.join(" ");
773
+ }
774
+
775
+ export function formatBtestSessionText(record: BtestSessionRecord): string {
776
+ const rate =
777
+ record.direction === "receive"
778
+ ? `rx ${formatBps(record.rxAvgBps)}`
779
+ : record.direction === "transmit"
780
+ ? `tx ${formatBps(record.txAvgBps)}`
781
+ : `tx ${formatBps(record.txAvgBps)} / rx ${formatBps(record.rxAvgBps)}`;
782
+ const auth = record.authKind === "none" ? "" : ` user=${record.user}`;
783
+ return `session ${record.client} ${record.protocol} ${record.direction}${auth} ${rate}`;
784
+ }
785
+
786
+ export function renderBtestClientEnvelope(
787
+ envelope: BtestClientEnvelope,
788
+ format: BtestOutputFormat,
789
+ ): string {
790
+ if (format === "json") return JSON.stringify(envelope, null, 2);
791
+ if (format === "yaml") return renderYaml(envelope);
792
+ if (format === "csv") return renderClientCsv(envelope);
793
+ return renderClientText(envelope);
794
+ }
795
+
796
+ export function renderBtestServerEnvelope(
797
+ envelope: BtestServerEnvelope,
798
+ format: BtestOutputFormat,
799
+ ): string {
800
+ if (format === "json") return JSON.stringify(envelope, null, 2);
801
+ if (format === "yaml") return renderYaml(envelope);
802
+ if (format === "csv") return renderServerCsv(envelope);
803
+ return renderServerText(envelope);
804
+ }
805
+
806
+ function renderClientCsv(envelope: BtestClientEnvelope): string {
807
+ const lines = [BTEST_CLIENT_CSV_HEADER];
808
+ if (envelope.ok)
809
+ for (const r of envelope.data.reports) lines.push(btestClientCsvRow(r));
810
+ else
811
+ lines.push(`# error: [${envelope.error.code}] ${envelope.error.summary}`);
812
+ return lines.join("\n");
813
+ }
814
+
815
+ function renderServerCsv(envelope: BtestServerEnvelope): string {
816
+ const lines = [BTEST_SERVER_CSV_HEADER];
817
+ if (envelope.ok)
818
+ for (const s of envelope.data.sessions) lines.push(btestServerCsvRow(s));
819
+ else
820
+ lines.push(`# error: [${envelope.error.code}] ${envelope.error.summary}`);
821
+ return lines.join("\n");
822
+ }
823
+
824
+ /** The single trailing summary line for a finished client run. */
825
+ export function btestClientSummaryLine(d: BtestClientData): string {
826
+ const total =
827
+ d.direction === "transmit"
828
+ ? `tx ${formatBps(d.txTotalAvgBps)}`
829
+ : d.direction === "receive"
830
+ ? `rx ${formatBps(d.rxTotalAvgBps)}`
831
+ : `tx ${formatBps(d.txTotalAvgBps)} / rx ${formatBps(d.rxTotalAvgBps)}`;
832
+ return (
833
+ `${d.protocol} ${d.direction} done: ${total} over ${(d.durationMs / 1000).toFixed(1)}s` +
834
+ (d.totalLostPackets > 0 ? `, lost ${d.totalLostPackets}` : "") +
835
+ ` (${d.stopReason})`
836
+ );
837
+ }
838
+
839
+ /** The header line for a finished server run. */
840
+ export function btestServerSummaryLine(d: BtestServerData): string {
841
+ return `btest server ${d.bind}:${d.port} (${d.sessions.length} session(s), ${d.stopReason})`;
842
+ }
843
+
844
+ function renderClientText(envelope: BtestClientEnvelope): string {
845
+ if (!envelope.ok) return formatErrorText(envelope.error);
846
+ const lines = envelope.data.reports.map(formatBtestReportText);
847
+ lines.push(btestClientSummaryLine(envelope.data));
848
+ return lines.join("\n");
849
+ }
850
+
851
+ function renderServerText(envelope: BtestServerEnvelope): string {
852
+ if (!envelope.ok) return formatErrorText(envelope.error);
853
+ const lines = [btestServerSummaryLine(envelope.data)];
854
+ for (const s of envelope.data.sessions) lines.push(formatBtestSessionText(s));
855
+ for (const w of envelope.warnings)
856
+ lines.push(`warning: [${w.code}] ${w.message}`);
857
+ return lines.join("\n");
858
+ }
859
+
860
+ function formatErrorText(error: {
861
+ code: string;
862
+ summary: string;
863
+ remediation?: string;
864
+ detailsUrl?: string;
865
+ }): string {
866
+ const lines = [`[${error.code}] ${error.summary}`];
867
+ if (error.remediation) lines.push(`Fix: ${error.remediation}`);
868
+ if (error.detailsUrl) lines.push(`Details: ${error.detailsUrl}`);
869
+ return lines.join("\n");
870
+ }
871
+
872
+ // Compact YAML renderer (per-command, matching the discover surface).
873
+ function renderYaml(value: unknown, indent = ""): string {
874
+ if (value === null || value === undefined) return `${indent}null`;
875
+ if (typeof value === "string") return JSON.stringify(value);
876
+ if (typeof value === "number" || typeof value === "boolean")
877
+ return String(value);
878
+ if (Array.isArray(value)) {
879
+ if (value.length === 0) return "[]";
880
+ return value
881
+ .map((item) => `${indent}- ${yamlInline(item, `${indent} `)}`)
882
+ .join("\n");
883
+ }
884
+ if (typeof value === "object") {
885
+ const entries = Object.entries(value as Record<string, unknown>).filter(
886
+ ([, v]) => v !== undefined,
887
+ );
888
+ if (entries.length === 0) return "{}";
889
+ return entries
890
+ .map(([k, v]) => {
891
+ const inline = yamlInline(v, `${indent} `);
892
+ return inline.includes("\n")
893
+ ? `${indent}${k}:\n${inline}`
894
+ : `${indent}${k}: ${inline}`;
895
+ })
896
+ .join("\n");
897
+ }
898
+ return String(value);
899
+ }
900
+
901
+ function yamlInline(value: unknown, indent: string): string {
902
+ if (Array.isArray(value) || (value !== null && typeof value === "object")) {
903
+ return `\n${renderYaml(value, indent)}`;
904
+ }
905
+ return renderYaml(value, indent);
906
+ }