@tikoci/centrs 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/devices/README.md +26 -0
- package/commands/discover/README.md +17 -8
- package/commands/discover/examples.md +10 -5
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/btest.ts +45 -1
- package/src/cli/common.ts +64 -0
- package/src/cli/discover.ts +26 -0
- package/src/cli/execute.ts +30 -4
- package/src/cli/missing-target.ts +171 -0
- package/src/cli/retrieve.ts +32 -14
- package/src/cli/terminal.ts +18 -4
- package/src/cli/transfer.ts +37 -9
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/discover.ts +96 -23
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
|
@@ -24,9 +24,15 @@
|
|
|
24
24
|
* client↔server matrix is loopback-testable without a router; the CHR
|
|
25
25
|
* integration test (`test/integration/btest.test.ts`) is the real-RouterOS gate.
|
|
26
26
|
*
|
|
27
|
-
* Scope:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Scope: UDP, plus TCP single- **and** multi-connection. For `connection-count > 1`
|
|
28
|
+
* the client reads the session token from the primary's OK, opens the extra data
|
|
29
|
+
* connections (each: read the server HELLO, send the 16-byte join
|
|
30
|
+
* `[token:u16 BE][0x02][0 …]`, then bulk data flows — the server sends **no** ack
|
|
31
|
+
* before data), and drives them into one shared `BandwidthCounters` so throughput
|
|
32
|
+
* aggregates. The secondary-join format is grounded byte-for-byte against RouterOS
|
|
33
|
+
* 7.23.1 (captured all directions; the `0x02` marker is direction-independent).
|
|
34
|
+
* Authenticated (EC-SRP5) multi-connection is not yet supported — the post-auth
|
|
35
|
+
* token is not captured — so those sessions stay single-stream.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { createSocket } from "node:dgram";
|
|
@@ -44,6 +50,7 @@ import {
|
|
|
44
50
|
type BtestCommandOptions,
|
|
45
51
|
type BtestDirection,
|
|
46
52
|
type BtestProtocol,
|
|
53
|
+
type BtestStatus,
|
|
47
54
|
classifyAuthResponse,
|
|
48
55
|
clientUdpPort,
|
|
49
56
|
decodeCommand,
|
|
@@ -54,6 +61,7 @@ import {
|
|
|
54
61
|
encodeClientHello,
|
|
55
62
|
encodeCommand,
|
|
56
63
|
encodeConfirmation,
|
|
64
|
+
encodeSecondaryJoin,
|
|
57
65
|
encodeServerChallenge,
|
|
58
66
|
encodeStatus,
|
|
59
67
|
encodeUdpPacket,
|
|
@@ -534,10 +542,6 @@ export function speedFeedbackBps(bytesReceived: number): number {
|
|
|
534
542
|
return Math.min(Math.floor((bytesReceived * 8 * 3) / 2), 0xffffffff);
|
|
535
543
|
}
|
|
536
544
|
|
|
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
545
|
// ── Handshake ─────────────────────────────────────────────────────────────────
|
|
542
546
|
|
|
543
547
|
/** What both sides agree on after the control handshake. */
|
|
@@ -860,6 +864,12 @@ interface RunContext {
|
|
|
860
864
|
serverUdpPort?: number;
|
|
861
865
|
/** Peer host for UDP (client: server host; server: client host). */
|
|
862
866
|
udpPeerHost?: string;
|
|
867
|
+
/**
|
|
868
|
+
* Adapt this side's TX rate from peer status frames (client transmit / both).
|
|
869
|
+
* Set so the merged-stream status reader in `tcpRxLoop` and the dedicated
|
|
870
|
+
* `tcpStatusReaderLoop` share one feedback gate.
|
|
871
|
+
*/
|
|
872
|
+
adaptTxFromStatus?: boolean;
|
|
863
873
|
onInterval?: (sample: BtestIntervalSample) => void;
|
|
864
874
|
}
|
|
865
875
|
|
|
@@ -884,6 +894,51 @@ function serverDirections(command: BtestCommand): RoleDirections {
|
|
|
884
894
|
};
|
|
885
895
|
}
|
|
886
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Open one secondary TCP data connection that joins an existing multi-connection
|
|
899
|
+
* test. Grounded against RouterOS 7.23.1: the server sends the 4-byte HELLO, the
|
|
900
|
+
* client replies with the 16-byte join `[token:u16 BE][0x02][0 …]`
|
|
901
|
+
* ({@link encodeSecondaryJoin}), and bulk data then flows. The server sends **no**
|
|
902
|
+
* acknowledgement before the data — it waits for *all* connections to join before
|
|
903
|
+
* streaming — so the join must not block on a reply read, or the sequential opens
|
|
904
|
+
* would deadlock (the server stalls waiting for the rest while the client stalls
|
|
905
|
+
* waiting for a reply that never comes, and the server eventually drops the
|
|
906
|
+
* connection). The caller drives the returned channel into the primary session's
|
|
907
|
+
* shared counters so throughput aggregates across all streams.
|
|
908
|
+
*/
|
|
909
|
+
async function openSecondaryConnection(
|
|
910
|
+
connect: (host: string, port: number) => Promise<BtestControlChannel>,
|
|
911
|
+
host: string,
|
|
912
|
+
port: number,
|
|
913
|
+
token: number,
|
|
914
|
+
): Promise<BtestControlChannel> {
|
|
915
|
+
const channel = await connect(host, port);
|
|
916
|
+
try {
|
|
917
|
+
await channel.readExact(4); // server HELLO (01 00 00 00)
|
|
918
|
+
await channel.write(encodeSecondaryJoin(token));
|
|
919
|
+
return channel;
|
|
920
|
+
} catch (error) {
|
|
921
|
+
channel.close();
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Run the bulk TX/RX loops for a secondary data connection. No status or report
|
|
928
|
+
* loops — the primary connection owns those; a secondary only moves bulk bytes
|
|
929
|
+
* into the shared counters. TX paces from the shared `counters.txSpeed` the primary
|
|
930
|
+
* adapts from server feedback.
|
|
931
|
+
*/
|
|
932
|
+
async function runSecondaryDataLoops(
|
|
933
|
+
ctx: RunContext,
|
|
934
|
+
dirs: RoleDirections,
|
|
935
|
+
): Promise<void> {
|
|
936
|
+
const tasks: Promise<void>[] = [];
|
|
937
|
+
if (dirs.shouldTx) tasks.push(tcpTxLoop(ctx, false));
|
|
938
|
+
if (dirs.shouldRx) tasks.push(tcpRxLoop(ctx));
|
|
939
|
+
await Promise.all(tasks);
|
|
940
|
+
}
|
|
941
|
+
|
|
887
942
|
/** Bulk TCP send loop: writes `txSize` zero/random payloads until stopped. */
|
|
888
943
|
async function tcpTxLoop(ctx: RunContext, sendStatus: boolean): Promise<void> {
|
|
889
944
|
await sleep(100);
|
|
@@ -932,7 +987,92 @@ async function tcpTxLoop(ctx: RunContext, sendStatus: boolean): Promise<void> {
|
|
|
932
987
|
}
|
|
933
988
|
}
|
|
934
989
|
|
|
935
|
-
/**
|
|
990
|
+
/**
|
|
991
|
+
* Apply a decoded peer status to the counters: record the peer CPU and, when this
|
|
992
|
+
* side adapts (client transmit / both), retarget TX from the peer's `bytesReceived`
|
|
993
|
+
* speed-feedback. Shared by the dedicated `tcpStatusReaderLoop` (pure status stream)
|
|
994
|
+
* and the merged-stream demux in `tcpRxLoop`.
|
|
995
|
+
*/
|
|
996
|
+
function applyStatusFeedback(ctx: RunContext, status: BtestStatus): void {
|
|
997
|
+
const { counters } = ctx;
|
|
998
|
+
counters.remoteCpu = status.cpuLoad;
|
|
999
|
+
if (ctx.adaptTxFromStatus && status.bytesReceived > 0) {
|
|
1000
|
+
counters.txSpeed = speedFeedbackBps(status.bytesReceived);
|
|
1001
|
+
counters.txSpeedChanged = true;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Demux the most recent 12-byte status frame embedded in a bulk TCP chunk and apply
|
|
1007
|
+
* it. In `direction=both` the peer interleaves status frames into the same bulk
|
|
1008
|
+
* stream with no length framing, so they are found structurally: a frame is
|
|
1009
|
+
* `[0x07][cpu][0x00][0x00][seq u32 LE][bytesReceived u32 LE]` — the two reserved
|
|
1010
|
+
* bytes are always zero, making `0x07 ?? 00 00` a reliable marker in the default
|
|
1011
|
+
* zero-filled payload, and it matches both client (CPU high-bit set) and RouterOS
|
|
1012
|
+
* server (high-bit clear) frames. A `--random-data` payload can yield a rare false
|
|
1013
|
+
* positive, and a frame split across a read boundary is missed, but TX speed is a
|
|
1014
|
+
* soft pacing target that self-corrects on the next interval's frame. Only the last
|
|
1015
|
+
* frame in the chunk is applied (the freshest feedback); the scan is in place, so it
|
|
1016
|
+
* adds no copy on the RX hot path. The frame bytes stay counted in `rx` (matching
|
|
1017
|
+
* RouterOS, which bills the overhead as stream bytes).
|
|
1018
|
+
*/
|
|
1019
|
+
/**
|
|
1020
|
+
* Sanity ceiling for an embedded-status sequence number. Status frames are emitted
|
|
1021
|
+
* once per `statusIntervalMs` (≥ 20 ms). We bound plausibility to a conservative
|
|
1022
|
+
* long-session window: 6 hours at the minimum 20 ms cadence yields
|
|
1023
|
+
* `(6 * 60 * 60 * 1000) / 20 = 1_080_000` frames, so any candidate sequence above
|
|
1024
|
+
* that is treated as random-noise fallout from a false marker match.
|
|
1025
|
+
*/
|
|
1026
|
+
const BTEST_STATUS_MIN_INTERVAL_MS = 20;
|
|
1027
|
+
const BTEST_STATUS_MAX_RUN_HOURS = 6;
|
|
1028
|
+
const BTEST_STATUS_SEQ_SANE_MAX = Math.ceil(
|
|
1029
|
+
(BTEST_STATUS_MAX_RUN_HOURS * 60 * 60 * 1000) / BTEST_STATUS_MIN_INTERVAL_MS,
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Validate a candidate embedded-status window before trusting it. The structural
|
|
1034
|
+
* marker `0x07 ?? 00 00` is only a 24-bit sentinel, so in a `--random-data` bulk
|
|
1035
|
+
* stream it false-matches roughly once per 16 MB (~once/second at 100 Mb/s) — and a
|
|
1036
|
+
* false frame would feed a garbage `bytesReceived` into `applyStatusFeedback`,
|
|
1037
|
+
* rewriting `counters.txSpeed`. Two cheap, payload-independent invariants reject
|
|
1038
|
+
* those: the CPU byte is a 0..100 percentage (decodeStatus *clamps* it, so check the
|
|
1039
|
+
* raw byte), and the status sequence is a small monotonic counter, never a random
|
|
1040
|
+
* 32-bit value (a bounded run can't reach `BTEST_STATUS_SEQ_SANE_MAX`). Together with
|
|
1041
|
+
* the marker this drops the false-apply rate to negligible while leaving real frames
|
|
1042
|
+
* untouched.
|
|
1043
|
+
*/
|
|
1044
|
+
function plausibleStatusFrame(frame: Uint8Array): boolean {
|
|
1045
|
+
if (((frame[1] as number) & 0x7f) > 100) return false;
|
|
1046
|
+
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
1047
|
+
return view.getUint32(4, true) <= BTEST_STATUS_SEQ_SANE_MAX;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
export function applyEmbeddedStatus(ctx: RunContext, chunk: Uint8Array): void {
|
|
1051
|
+
if (chunk.length < BTEST_STATUS_MSG_SIZE) return;
|
|
1052
|
+
const limit = chunk.length - BTEST_STATUS_MSG_SIZE;
|
|
1053
|
+
let lastValid: BtestStatus | undefined;
|
|
1054
|
+
let i = 0;
|
|
1055
|
+
while (i <= limit) {
|
|
1056
|
+
const frame = chunk.subarray(i, i + BTEST_STATUS_MSG_SIZE);
|
|
1057
|
+
if (
|
|
1058
|
+
chunk[i] === BTEST_STATUS_MSG_TYPE &&
|
|
1059
|
+
chunk[i + 2] === 0 &&
|
|
1060
|
+
chunk[i + 3] === 0 &&
|
|
1061
|
+
plausibleStatusFrame(frame)
|
|
1062
|
+
) {
|
|
1063
|
+
// A real status frame's own seq/bytes fields can contain `0x07 ?? 00 00`
|
|
1064
|
+
// (e.g. seq 7), so consume the whole frame before scanning on — never
|
|
1065
|
+
// re-match a matched frame's payload as a second, garbage frame.
|
|
1066
|
+
lastValid = decodeStatus(frame);
|
|
1067
|
+
i += BTEST_STATUS_MSG_SIZE;
|
|
1068
|
+
} else {
|
|
1069
|
+
i += 1;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (lastValid) applyStatusFeedback(ctx, lastValid);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Bulk TCP receive loop: counts bytes and demuxes interleaved status frames. */
|
|
936
1076
|
async function tcpRxLoop(ctx: RunContext): Promise<void> {
|
|
937
1077
|
const { counters } = ctx;
|
|
938
1078
|
while (counters.running) {
|
|
@@ -942,14 +1082,7 @@ async function tcpRxLoop(ctx: RunContext): Promise<void> {
|
|
|
942
1082
|
break;
|
|
943
1083
|
}
|
|
944
1084
|
counters.addRx(chunk.length);
|
|
945
|
-
|
|
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
|
-
}
|
|
1085
|
+
applyEmbeddedStatus(ctx, chunk);
|
|
953
1086
|
}
|
|
954
1087
|
}
|
|
955
1088
|
|
|
@@ -960,12 +1093,7 @@ async function tcpStatusReaderLoop(ctx: RunContext): Promise<void> {
|
|
|
960
1093
|
const frame = await readExactOrStop(ctx, BTEST_STATUS_MSG_SIZE);
|
|
961
1094
|
if (frame === null) break;
|
|
962
1095
|
if (frame[0] !== BTEST_STATUS_MSG_TYPE) continue;
|
|
963
|
-
|
|
964
|
-
counters.remoteCpu = status.cpuLoad;
|
|
965
|
-
if (status.bytesReceived > 0) {
|
|
966
|
-
counters.txSpeed = speedFeedbackBps(status.bytesReceived);
|
|
967
|
-
counters.txSpeedChanged = true;
|
|
968
|
-
}
|
|
1096
|
+
applyStatusFeedback(ctx, decodeStatus(frame));
|
|
969
1097
|
}
|
|
970
1098
|
}
|
|
971
1099
|
|
|
@@ -1024,6 +1152,8 @@ async function udpTxLoop(
|
|
|
1024
1152
|
// yields the event loop every iteration (sleep, or setImmediate when
|
|
1025
1153
|
// unlimited), so a slot's prior send has flushed long before we cycle back to
|
|
1026
1154
|
// it. The first allocation also runs encodeUdpPacket's size guard (≥ 4 bytes).
|
|
1155
|
+
// Keep 8 slots so each buffer is only reused after multiple pace() yields;
|
|
1156
|
+
// this gives node:dgram ample time to flush queued sends before a slot cycles.
|
|
1027
1157
|
const RING = 8;
|
|
1028
1158
|
const ring = Array.from({ length: RING }, (_, i) => {
|
|
1029
1159
|
const buf =
|
|
@@ -1157,6 +1287,8 @@ export interface BtestClientSessionOptions {
|
|
|
1157
1287
|
remoteTxSpeed?: number;
|
|
1158
1288
|
/** UDP per-packet size; TCP uses its bulk default. */
|
|
1159
1289
|
txSize?: number;
|
|
1290
|
+
/** Parallel TCP data connections (TCP only); encoded in command byte 3. */
|
|
1291
|
+
tcpConnectionCount?: number;
|
|
1160
1292
|
randomData?: boolean;
|
|
1161
1293
|
natMode?: boolean;
|
|
1162
1294
|
durationMs?: number;
|
|
@@ -1181,6 +1313,12 @@ export interface BtestRunSummary {
|
|
|
1181
1313
|
stopReason: BtestStopReason;
|
|
1182
1314
|
serverUdpPort?: number;
|
|
1183
1315
|
clientUdpPort?: number;
|
|
1316
|
+
/**
|
|
1317
|
+
* TCP data connections actually driven (1 primary + joined secondaries). For a
|
|
1318
|
+
* `connection-count > 1` request this is the realized fan-out; it stays 1 when
|
|
1319
|
+
* the server negotiates no session token (e.g. authenticated sessions).
|
|
1320
|
+
*/
|
|
1321
|
+
activeConnections: number;
|
|
1184
1322
|
}
|
|
1185
1323
|
|
|
1186
1324
|
/** Run a full btest **client** session and return its aggregate summary. */
|
|
@@ -1200,6 +1338,9 @@ export async function runBtestClientSession(
|
|
|
1200
1338
|
? { randomData: options.randomData }
|
|
1201
1339
|
: {}),
|
|
1202
1340
|
...(options.txSize !== undefined ? { txSize: options.txSize } : {}),
|
|
1341
|
+
...(options.tcpConnectionCount !== undefined
|
|
1342
|
+
? { tcpConnectionCount: options.tcpConnectionCount }
|
|
1343
|
+
: {}),
|
|
1203
1344
|
...(options.localTxSpeed !== undefined
|
|
1204
1345
|
? { localTxSpeed: options.localTxSpeed }
|
|
1205
1346
|
: {}),
|
|
@@ -1222,14 +1363,38 @@ export async function runBtestClientSession(
|
|
|
1222
1363
|
counters.txSpeed = options.localTxSpeed ?? 0;
|
|
1223
1364
|
const dirs = clientDirections(negotiated.command);
|
|
1224
1365
|
|
|
1366
|
+
// Reuse the IP node already resolved for the TCP control connection as the UDP
|
|
1367
|
+
// peer address. The client now addresses every datagram explicitly (no
|
|
1368
|
+
// connect()), so passing a hostname here would make node:dgram run a DNS
|
|
1369
|
+
// lookup per send on the UDP hot path; channel.remoteAddress is already an IP.
|
|
1370
|
+
const udpPeerHost = channel.remoteAddress ?? options.host;
|
|
1371
|
+
|
|
1225
1372
|
let udp: BtestUdpSocket | undefined;
|
|
1226
1373
|
if (options.protocol === "udp") {
|
|
1227
1374
|
udp = (options.createUdpSocket ?? createBtestUdpSocket)();
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
//
|
|
1232
|
-
|
|
1375
|
+
try {
|
|
1376
|
+
await udp.bind(negotiated.clientUdpPort as number, "0.0.0.0");
|
|
1377
|
+
// Do NOT connect() the socket: connect() installs a kernel receive filter
|
|
1378
|
+
// that silently drops datagrams whose source port differs from the connected
|
|
1379
|
+
// peer. RouterOS may send UDP data from a different source port than the
|
|
1380
|
+
// negotiated serverUdpPort (separate TX socket), so the filter would discard
|
|
1381
|
+
// all incoming packets and leave rx at 0 bps throughout. Instead, keep the
|
|
1382
|
+
// socket unconnected (like the server side) and always address sends
|
|
1383
|
+
// explicitly via udpTxLoop's target argument.
|
|
1384
|
+
if (options.natMode || dirs.shouldRx) {
|
|
1385
|
+
// Originate a flow so the server's datagrams can return (NAT/SLIRP).
|
|
1386
|
+
udp.send(
|
|
1387
|
+
new Uint8Array(0),
|
|
1388
|
+
negotiated.serverUdpPort as number,
|
|
1389
|
+
udpPeerHost,
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
// bind()/send() can throw before the normal close path runs; release the
|
|
1394
|
+
// socket and the control channel so a UDP bootstrap failure leaks neither.
|
|
1395
|
+
udp.close();
|
|
1396
|
+
channel.close();
|
|
1397
|
+
throw error;
|
|
1233
1398
|
}
|
|
1234
1399
|
}
|
|
1235
1400
|
|
|
@@ -1248,19 +1413,78 @@ export async function runBtestClientSession(
|
|
|
1248
1413
|
...(negotiated.serverUdpPort !== undefined
|
|
1249
1414
|
? { serverUdpPort: negotiated.serverUdpPort }
|
|
1250
1415
|
: {}),
|
|
1251
|
-
udpPeerHost
|
|
1416
|
+
udpPeerHost,
|
|
1252
1417
|
...(options.onInterval ? { onInterval: options.onInterval } : {}),
|
|
1253
1418
|
};
|
|
1254
1419
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1420
|
+
// TCP multi-connection fan-out: the server negotiated a session token, so open
|
|
1421
|
+
// the requested extra data connections and drive them into the **shared**
|
|
1422
|
+
// counters, concurrently with the primary, so throughput aggregates across all
|
|
1423
|
+
// streams (grounded against RouterOS 7.23.1). A secondary that fails to open
|
|
1424
|
+
// degrades to fewer streams rather than failing the test — the primary already
|
|
1425
|
+
// carries the session. They share `counters.running`, so the primary's
|
|
1426
|
+
// duration/abort stop unblocks them too.
|
|
1427
|
+
const secondaryChannels: BtestControlChannel[] = [];
|
|
1428
|
+
if (
|
|
1429
|
+
options.protocol === "tcp" &&
|
|
1430
|
+
negotiated.sessionToken !== undefined &&
|
|
1431
|
+
(options.tcpConnectionCount ?? 1) > 1
|
|
1432
|
+
) {
|
|
1433
|
+
// Dial secondaries at the **same peer** the primary control socket resolved
|
|
1434
|
+
// to, not a fresh `options.host` lookup: a hostname that load-balances across
|
|
1435
|
+
// addresses could otherwise land a secondary on a different RouterOS, where
|
|
1436
|
+
// the session token is unknown and the join fails.
|
|
1437
|
+
const secondaryHost = channel.remoteAddress ?? options.host;
|
|
1438
|
+
const extra = (options.tcpConnectionCount as number) - 1;
|
|
1439
|
+
for (let i = 0; i < extra; i += 1) {
|
|
1440
|
+
try {
|
|
1441
|
+
secondaryChannels.push(
|
|
1442
|
+
await openSecondaryConnection(
|
|
1443
|
+
connect,
|
|
1444
|
+
secondaryHost,
|
|
1445
|
+
controlPort,
|
|
1446
|
+
negotiated.sessionToken,
|
|
1447
|
+
),
|
|
1448
|
+
);
|
|
1449
|
+
} catch {
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const secondaryLoops = secondaryChannels.map((secondaryChannel) =>
|
|
1455
|
+
runSecondaryDataLoops(
|
|
1456
|
+
{
|
|
1457
|
+
channel: secondaryChannel,
|
|
1458
|
+
command: negotiated.command,
|
|
1459
|
+
counters,
|
|
1460
|
+
txSize,
|
|
1461
|
+
txSpeed: options.localTxSpeed ?? 0,
|
|
1462
|
+
randomData: options.randomData ?? false,
|
|
1463
|
+
statusIntervalMs: options.statusIntervalMs ?? BTEST_STATUS_INTERVAL_MS,
|
|
1464
|
+
},
|
|
1465
|
+
dirs,
|
|
1466
|
+
),
|
|
1467
|
+
);
|
|
1261
1468
|
|
|
1262
|
-
|
|
1263
|
-
channel.
|
|
1469
|
+
// Close every socket even if a loop throws — a rejected Promise.all must not leak
|
|
1470
|
+
// the UDP socket or any open TCP channel.
|
|
1471
|
+
let stopReason: BtestStopReason;
|
|
1472
|
+
try {
|
|
1473
|
+
const [primaryStopReason] = await Promise.all([
|
|
1474
|
+
driveSession(ctx, dirs, "client", {
|
|
1475
|
+
...(options.durationMs !== undefined
|
|
1476
|
+
? { durationMs: options.durationMs }
|
|
1477
|
+
: {}),
|
|
1478
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1479
|
+
}),
|
|
1480
|
+
...secondaryLoops,
|
|
1481
|
+
]);
|
|
1482
|
+
stopReason = primaryStopReason;
|
|
1483
|
+
} finally {
|
|
1484
|
+
udp?.close();
|
|
1485
|
+
channel.close();
|
|
1486
|
+
for (const secondaryChannel of secondaryChannels) secondaryChannel.close();
|
|
1487
|
+
}
|
|
1264
1488
|
|
|
1265
1489
|
const sum = counters.summary();
|
|
1266
1490
|
const summary: BtestRunSummary = {
|
|
@@ -1273,6 +1497,7 @@ export async function runBtestClientSession(
|
|
|
1273
1497
|
intervals: sum.intervals,
|
|
1274
1498
|
durationMs: Date.now() - start,
|
|
1275
1499
|
stopReason,
|
|
1500
|
+
activeConnections: 1 + secondaryChannels.length,
|
|
1276
1501
|
};
|
|
1277
1502
|
if (negotiated.username !== undefined) summary.username = negotiated.username;
|
|
1278
1503
|
if (negotiated.serverUdpPort !== undefined)
|
|
@@ -1430,20 +1655,28 @@ async function driveSession(
|
|
|
1430
1655
|
|
|
1431
1656
|
const tasks: Promise<void>[] = [];
|
|
1432
1657
|
if (ctx.command.protocol === "udp") {
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
:
|
|
1658
|
+
// Both client and server address sends explicitly (no connected-socket
|
|
1659
|
+
// default): client → serverUdpPort, server → clientUdpPort. An unconnected
|
|
1660
|
+
// socket accepts datagrams from any source, avoiding the BSD receive filter
|
|
1661
|
+
// that connect() installs and that silently drops packets when RouterOS
|
|
1662
|
+
// sends from an unexpected source port.
|
|
1663
|
+
let target: { port: number; host: string } | undefined;
|
|
1664
|
+
if (ctx.udpPeerHost !== undefined) {
|
|
1665
|
+
if (role === "server" && ctx.clientUdpPort !== undefined) {
|
|
1666
|
+
target = { port: ctx.clientUdpPort, host: ctx.udpPeerHost };
|
|
1667
|
+
} else if (role === "client" && ctx.serverUdpPort !== undefined) {
|
|
1668
|
+
target = { port: ctx.serverUdpPort, host: ctx.udpPeerHost };
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1442
1671
|
if (dirs.shouldTx) tasks.push(udpTxLoop(ctx, target));
|
|
1443
1672
|
if (dirs.shouldRx) tasks.push(udpRxLoop(ctx));
|
|
1444
1673
|
tasks.push(udpStatusExchange(ctx, dirs));
|
|
1445
1674
|
} else {
|
|
1446
|
-
//
|
|
1675
|
+
// A transmitting client paces its TX from the server's status feedback. For
|
|
1676
|
+
// `transmit` that arrives on the dedicated status stream (tcpStatusReaderLoop);
|
|
1677
|
+
// for `both` it is interleaved into the bulk RX stream (tcpRxLoop's demux).
|
|
1678
|
+
// Either way this gate routes the feedback into counters.txSpeed.
|
|
1679
|
+
ctx.adaptTxFromStatus = role === "client" && dirs.shouldTx;
|
|
1447
1680
|
if (dirs.shouldTx && dirs.shouldRx) {
|
|
1448
1681
|
tasks.push(tcpTxLoop(ctx, role === "server"));
|
|
1449
1682
|
tasks.push(tcpRxLoop(ctx));
|
package/src/protocols/btest.ts
CHANGED
|
@@ -254,14 +254,18 @@ export function sessionTokenFromResponse(buf: Uint8Array): number {
|
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
256
|
* The 16-byte "command" a secondary TCP connection sends to join an existing
|
|
257
|
-
* session — the token in bytes 0-1 (big-endian),
|
|
258
|
-
*
|
|
257
|
+
* session — the token in bytes 0-1 (big-endian), a constant `0x02` join marker in
|
|
258
|
+
* byte 2, the rest zero. The connection joins without auth; the server sends **no**
|
|
259
|
+
* acknowledgement before it starts streaming bulk data, so the client must not
|
|
260
|
+
* await one. Grounded byte-for-byte against RouterOS 7.23.1 (`12 34 02 00 …`,
|
|
261
|
+
* direction-independent — see the btest-session.ts header).
|
|
259
262
|
*/
|
|
260
263
|
export function encodeSecondaryJoin(sessionToken: number): Uint8Array {
|
|
261
264
|
assertField(sessionToken, U16_MAX, "session-token");
|
|
262
265
|
const buf = new Uint8Array(BTEST_COMMAND_SIZE);
|
|
263
266
|
buf[0] = (sessionToken >> 8) & 0xff;
|
|
264
267
|
buf[1] = sessionToken & 0xff;
|
|
268
|
+
buf[2] = 0x02;
|
|
265
269
|
return buf;
|
|
266
270
|
}
|
|
267
271
|
|