@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.
- package/CHANGELOG.md +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- 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
|
+
};
|