@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,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MikroTik bandwidth-test (btest) wire codec — the pure, side-effect-free half
|
|
3
|
+
* of the btest protocol. TCP control on port 2000; UDP data on dynamic ports.
|
|
4
|
+
*
|
|
5
|
+
* Grounded on `manawenuz/btest-rs` — the authoritative layouts here follow
|
|
6
|
+
* `src/protocol.rs` (the working Rust implementation, MITM-verified against
|
|
7
|
+
* RouterOS 7.x), which corrects a couple of ambiguities in the prose
|
|
8
|
+
* `docs/protocol.md`. Pinned to captured/derived vectors in
|
|
9
|
+
* `test/unit/btest.test.ts`. The session state machine, EC-SRP5 handshake (which
|
|
10
|
+
* wraps `ec-srp5.ts`), and the TCP/UDP data engines build on these primitives;
|
|
11
|
+
* the orchestrator + envelopes live in `src/btest.ts`.
|
|
12
|
+
*
|
|
13
|
+
* This codec is strict on purpose: the btest **server** is a network listener, so
|
|
14
|
+
* decoders reject malformed input (bad enum bytes, wrong lengths) rather than
|
|
15
|
+
* coercing it, and encoders reject out-of-range fields rather than silently
|
|
16
|
+
* masking them into valid-looking wire bytes (the preflight validator is the
|
|
17
|
+
* user-facing gate; this is the defense-in-depth backstop).
|
|
18
|
+
*
|
|
19
|
+
* EC-SRP5 auth uses btest framing `[len:1][payload]` — Winbox's `[len][0x06]
|
|
20
|
+
* [payload]` minus the `0x06` handler byte, since the auth context is implicit
|
|
21
|
+
* after the server's `03 00 00 00`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { CentrsError } from "../errors.ts";
|
|
25
|
+
|
|
26
|
+
// ── Constants (protocol.rs) ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** TCP control port. */
|
|
29
|
+
export const BTEST_PORT = 2000;
|
|
30
|
+
/** First server UDP data port (server allocates `2001 + offset`). */
|
|
31
|
+
export const BTEST_UDP_PORT_START = 2001;
|
|
32
|
+
/** Client UDP data port offset (`server_port + 256`). */
|
|
33
|
+
export const BTEST_UDP_CLIENT_OFFSET = 256;
|
|
34
|
+
|
|
35
|
+
/** 4-byte control words exchanged on the TCP channel. */
|
|
36
|
+
export const BTEST_HELLO = Uint8Array.of(0x01, 0x00, 0x00, 0x00);
|
|
37
|
+
export const BTEST_AUTH_OK = Uint8Array.of(0x01, 0x00, 0x00, 0x00);
|
|
38
|
+
export const BTEST_AUTH_MD5 = Uint8Array.of(0x02, 0x00, 0x00, 0x00);
|
|
39
|
+
export const BTEST_AUTH_EC_SRP5 = Uint8Array.of(0x03, 0x00, 0x00, 0x00);
|
|
40
|
+
export const BTEST_AUTH_FAILED = Uint8Array.of(0x00, 0x00, 0x00, 0x00);
|
|
41
|
+
|
|
42
|
+
/** Status message marker + size (12 bytes, exchanged ~1/s over TCP). */
|
|
43
|
+
export const BTEST_STATUS_MSG_TYPE = 0x07;
|
|
44
|
+
export const BTEST_STATUS_MSG_SIZE = 12;
|
|
45
|
+
|
|
46
|
+
/** 16-byte command packet size. */
|
|
47
|
+
export const BTEST_COMMAND_SIZE = 16;
|
|
48
|
+
/** EC-SRP5 server challenge (MSG2) payload size: pubkey(33) + salt(16). */
|
|
49
|
+
export const BTEST_EC_SRP5_CHALLENGE_LEN = 49;
|
|
50
|
+
/** Default per-packet sizes. */
|
|
51
|
+
export const BTEST_DEFAULT_TCP_TX_SIZE = 32768;
|
|
52
|
+
export const BTEST_DEFAULT_UDP_TX_SIZE = 1500;
|
|
53
|
+
|
|
54
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Transport the test runs over. */
|
|
57
|
+
export type BtestProtocol = "udp" | "tcp";
|
|
58
|
+
/** Direction from the **client's** perspective (matches RouterOS `direction`). */
|
|
59
|
+
export type BtestDirection = "transmit" | "receive" | "both";
|
|
60
|
+
|
|
61
|
+
/** Server-perspective direction byte (the wire encoding inverts the client). */
|
|
62
|
+
export const BTEST_DIR_RX = 0x01; // client transmit → server receives
|
|
63
|
+
export const BTEST_DIR_TX = 0x02; // client receive → server transmits
|
|
64
|
+
export const BTEST_DIR_BOTH = 0x03; // both directions
|
|
65
|
+
|
|
66
|
+
/** What the server should do — `01..` (no auth) / `02` MD5 / `03` EC-SRP5 / `00` failed. */
|
|
67
|
+
export type BtestAuthKind = "none" | "md5" | "ec-srp5" | "failed" | "unknown";
|
|
68
|
+
|
|
69
|
+
/** Inputs the client encodes into the 16-byte command packet. */
|
|
70
|
+
export interface BtestCommandOptions {
|
|
71
|
+
protocol: BtestProtocol;
|
|
72
|
+
/** Client-perspective direction; encoded as the inverted server-perspective byte. */
|
|
73
|
+
direction: BtestDirection;
|
|
74
|
+
/** `true` = incompressible random payload; `false` (default) = zero fill. */
|
|
75
|
+
randomData?: boolean;
|
|
76
|
+
/** Parallel TCP data connections (TCP only); 0..255. */
|
|
77
|
+
tcpConnectionCount?: number;
|
|
78
|
+
/** Per-packet size; defaults to the protocol default when omitted. 0..65535. */
|
|
79
|
+
txSize?: number;
|
|
80
|
+
/** Client buffer size hint (0 = default); 0..65535. */
|
|
81
|
+
clientBufferSize?: number;
|
|
82
|
+
/** Server→client speed cap, bits/sec (0 = unlimited); 0..2^32-1. */
|
|
83
|
+
remoteTxSpeed?: number;
|
|
84
|
+
/** Client→server speed cap, bits/sec (0 = unlimited); 0..2^32-1. */
|
|
85
|
+
localTxSpeed?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Decoded command, from the server's perspective. */
|
|
89
|
+
export interface BtestCommand {
|
|
90
|
+
protocol: BtestProtocol;
|
|
91
|
+
directionByte: number;
|
|
92
|
+
/** Server transmits to the client (client `receive`/`both`). */
|
|
93
|
+
serverTransmits: boolean;
|
|
94
|
+
/** Server receives from the client (client `transmit`/`both`). */
|
|
95
|
+
serverReceives: boolean;
|
|
96
|
+
randomData: boolean;
|
|
97
|
+
tcpConnectionCount: number;
|
|
98
|
+
txSize: number;
|
|
99
|
+
clientBufferSize: number;
|
|
100
|
+
remoteTxSpeed: number;
|
|
101
|
+
localTxSpeed: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Decoded 12-byte status message. */
|
|
105
|
+
export interface BtestStatus {
|
|
106
|
+
seq: number;
|
|
107
|
+
bytesReceived: number;
|
|
108
|
+
/** CPU load percentage 0..100 (the high bit MikroTik sets is stripped). */
|
|
109
|
+
cpuLoad: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Field guards (no silent truncation) ──────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const U16_MAX = 0xffff;
|
|
115
|
+
const U32_MAX = 0xffffffff;
|
|
116
|
+
|
|
117
|
+
/** Reject a field that does not fit its wire width rather than masking it. */
|
|
118
|
+
function assertField(value: number, max: number, field: string): void {
|
|
119
|
+
if (!Number.isInteger(value) || value < 0 || value > max) {
|
|
120
|
+
throw new CentrsError({
|
|
121
|
+
code: "validation/option",
|
|
122
|
+
summary: `btest ${field} must be an integer in 0..${max} (got ${value}).`,
|
|
123
|
+
remediation: "Pass a value within the btest wire field's range.",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Direction mapping ────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** Client-perspective direction → server-perspective wire byte. */
|
|
131
|
+
export function serverDirectionByte(direction: BtestDirection): number {
|
|
132
|
+
switch (direction) {
|
|
133
|
+
case "transmit":
|
|
134
|
+
return BTEST_DIR_RX;
|
|
135
|
+
case "receive":
|
|
136
|
+
return BTEST_DIR_TX;
|
|
137
|
+
case "both":
|
|
138
|
+
return BTEST_DIR_BOTH;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Default per-packet TX size for a protocol. */
|
|
143
|
+
export function defaultTxSize(protocol: BtestProtocol): number {
|
|
144
|
+
return protocol === "tcp"
|
|
145
|
+
? BTEST_DEFAULT_TCP_TX_SIZE
|
|
146
|
+
: BTEST_DEFAULT_UDP_TX_SIZE;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Command packet (16 bytes) ────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** Encode the client's 16-byte command packet (sent after HELLO). */
|
|
152
|
+
export function encodeCommand(options: BtestCommandOptions): Uint8Array {
|
|
153
|
+
const txSize = options.txSize ?? defaultTxSize(options.protocol);
|
|
154
|
+
const tcpConn = options.tcpConnectionCount ?? 0;
|
|
155
|
+
const bufSize = options.clientBufferSize ?? 0;
|
|
156
|
+
const remote = options.remoteTxSpeed ?? 0;
|
|
157
|
+
const local = options.localTxSpeed ?? 0;
|
|
158
|
+
assertField(tcpConn, 0xff, "connection-count");
|
|
159
|
+
assertField(txSize, U16_MAX, "tx-size");
|
|
160
|
+
assertField(bufSize, U16_MAX, "client-buffer-size");
|
|
161
|
+
assertField(remote, U32_MAX, "remote-tx-speed");
|
|
162
|
+
assertField(local, U32_MAX, "local-tx-speed");
|
|
163
|
+
|
|
164
|
+
const buf = new Uint8Array(BTEST_COMMAND_SIZE);
|
|
165
|
+
const view = new DataView(buf.buffer);
|
|
166
|
+
buf[0] = options.protocol === "tcp" ? 1 : 0;
|
|
167
|
+
buf[1] = serverDirectionByte(options.direction);
|
|
168
|
+
buf[2] = options.randomData ? 0x00 : 0x01; // 0x00=random, 0x01=zeros
|
|
169
|
+
buf[3] = tcpConn;
|
|
170
|
+
view.setUint16(4, txSize, true);
|
|
171
|
+
view.setUint16(6, bufSize, true);
|
|
172
|
+
view.setUint32(8, remote, true);
|
|
173
|
+
view.setUint32(12, local, true);
|
|
174
|
+
return buf;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decode a 16-byte command packet (server side). Rejects an invalid protocol
|
|
179
|
+
* byte (>1) or direction byte (not 1/2/3), matching `protocol.rs` `recv_command`.
|
|
180
|
+
*/
|
|
181
|
+
export function decodeCommand(buf: Uint8Array): BtestCommand {
|
|
182
|
+
if (buf.length < BTEST_COMMAND_SIZE) {
|
|
183
|
+
throw new CentrsError({
|
|
184
|
+
code: "routeros/btest-protocol",
|
|
185
|
+
summary: `btest command must be ${BTEST_COMMAND_SIZE} bytes (got ${buf.length}).`,
|
|
186
|
+
remediation:
|
|
187
|
+
"The peer sent a malformed command; confirm it is a btest client.",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const proto = buf[0] as number;
|
|
191
|
+
const directionByte = buf[1] as number;
|
|
192
|
+
if (proto > 1 || directionByte === 0 || directionByte > 3) {
|
|
193
|
+
throw new CentrsError({
|
|
194
|
+
code: "routeros/btest-protocol",
|
|
195
|
+
summary: `btest command has an invalid protocol/direction byte (proto=${proto}, dir=${directionByte}).`,
|
|
196
|
+
remediation:
|
|
197
|
+
"The peer sent a malformed command; confirm it is a btest client.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
201
|
+
return {
|
|
202
|
+
protocol: proto === 1 ? "tcp" : "udp",
|
|
203
|
+
directionByte,
|
|
204
|
+
serverTransmits: (directionByte & BTEST_DIR_TX) !== 0,
|
|
205
|
+
serverReceives: (directionByte & BTEST_DIR_RX) !== 0,
|
|
206
|
+
randomData: buf[2] === 0x00,
|
|
207
|
+
tcpConnectionCount: buf[3] as number,
|
|
208
|
+
txSize: view.getUint16(4, true),
|
|
209
|
+
clientBufferSize: view.getUint16(6, true),
|
|
210
|
+
remoteTxSpeed: view.getUint32(8, true),
|
|
211
|
+
localTxSpeed: view.getUint32(12, true),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Auth response + TCP multi-connection session token ───────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Classify the server's 4-byte response to the command packet. A `0x01` first
|
|
219
|
+
* byte is OK regardless of the rest — for a TCP multi-connection test the server
|
|
220
|
+
* answers `01 <tokenHi> <tokenLo> 00`, carrying the session token (see
|
|
221
|
+
* {@link sessionTokenFromResponse}).
|
|
222
|
+
*/
|
|
223
|
+
export function classifyAuthResponse(buf: Uint8Array): BtestAuthKind {
|
|
224
|
+
if (buf.length < 4) return "unknown";
|
|
225
|
+
switch (buf[0]) {
|
|
226
|
+
case 0x00:
|
|
227
|
+
return "failed";
|
|
228
|
+
case 0x01:
|
|
229
|
+
return "none";
|
|
230
|
+
case 0x02:
|
|
231
|
+
return "md5";
|
|
232
|
+
case 0x03:
|
|
233
|
+
return "ec-srp5";
|
|
234
|
+
default:
|
|
235
|
+
return "unknown";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Build an OK response. `sessionToken` (0..65535) rides bytes 1-2 for TCP multi-conn. */
|
|
240
|
+
export function encodeAuthOk(sessionToken = 0): Uint8Array {
|
|
241
|
+
assertField(sessionToken, U16_MAX, "session-token");
|
|
242
|
+
return Uint8Array.of(
|
|
243
|
+
0x01,
|
|
244
|
+
(sessionToken >> 8) & 0xff,
|
|
245
|
+
sessionToken & 0xff,
|
|
246
|
+
0x00,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Read the TCP multi-connection session token from an OK response (bytes 1-2, big-endian). */
|
|
251
|
+
export function sessionTokenFromResponse(buf: Uint8Array): number {
|
|
252
|
+
return (((buf[1] ?? 0) << 8) | (buf[2] ?? 0)) >>> 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The 16-byte "command" a secondary TCP connection sends to join an existing
|
|
257
|
+
* session — the token in bytes 0-1 (big-endian), the rest zero. The server
|
|
258
|
+
* answers `01 <tokenHi> <tokenLo> 00` and the connection joins without auth.
|
|
259
|
+
*/
|
|
260
|
+
export function encodeSecondaryJoin(sessionToken: number): Uint8Array {
|
|
261
|
+
assertField(sessionToken, U16_MAX, "session-token");
|
|
262
|
+
const buf = new Uint8Array(BTEST_COMMAND_SIZE);
|
|
263
|
+
buf[0] = (sessionToken >> 8) & 0xff;
|
|
264
|
+
buf[1] = sessionToken & 0xff;
|
|
265
|
+
return buf;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read the session token a secondary connection sent (bytes 0-1, big-endian). */
|
|
269
|
+
export function sessionTokenFromSecondary(buf: Uint8Array): number {
|
|
270
|
+
return (((buf[0] ?? 0) << 8) | (buf[1] ?? 0)) >>> 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── EC-SRP5 auth framing: [len:1][payload] ───────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/** Wrap an EC-SRP5 payload in btest's single-byte length frame. */
|
|
276
|
+
export function encodeAuthFrame(payload: Uint8Array): Uint8Array {
|
|
277
|
+
if (payload.length > 0xff) {
|
|
278
|
+
throw new CentrsError({
|
|
279
|
+
code: "routeros/btest-protocol",
|
|
280
|
+
summary: `btest auth payload too long (${payload.length} > 255).`,
|
|
281
|
+
remediation: "Shorten the username; btest frames are single-byte-length.",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const out = new Uint8Array(payload.length + 1);
|
|
285
|
+
out[0] = payload.length;
|
|
286
|
+
out.set(payload, 1);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** MSG1: `[username\0][publicKey:33]`. `publicKey` is the 33-byte EC-SRP5 encoding. */
|
|
291
|
+
export function encodeClientHello(
|
|
292
|
+
username: string,
|
|
293
|
+
publicKey: Uint8Array,
|
|
294
|
+
): Uint8Array {
|
|
295
|
+
const name = new TextEncoder().encode(username);
|
|
296
|
+
const payload = new Uint8Array(name.length + 1 + publicKey.length);
|
|
297
|
+
payload.set(name, 0);
|
|
298
|
+
payload[name.length] = 0x00;
|
|
299
|
+
payload.set(publicKey, name.length + 1);
|
|
300
|
+
return encodeAuthFrame(payload);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Parse a MSG1 payload (the exact bytes after the length prefix). */
|
|
304
|
+
export function parseClientHello(payload: Uint8Array): {
|
|
305
|
+
username: string;
|
|
306
|
+
publicKey: Uint8Array;
|
|
307
|
+
} {
|
|
308
|
+
const nul = payload.indexOf(0x00);
|
|
309
|
+
// Exact framing: username\0 + 33-byte key, no trailing bytes.
|
|
310
|
+
if (nul < 0 || payload.length !== nul + 1 + 33) {
|
|
311
|
+
throw new CentrsError({
|
|
312
|
+
code: "routeros/btest-protocol",
|
|
313
|
+
summary: "btest EC-SRP5 client hello is malformed.",
|
|
314
|
+
remediation:
|
|
315
|
+
"The peer sent a bad auth message; confirm it is a btest client.",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
username: new TextDecoder().decode(payload.subarray(0, nul)),
|
|
320
|
+
publicKey: payload.subarray(nul + 1, nul + 1 + 33),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** MSG2: `[serverPublicKey:33][salt:16]`. */
|
|
325
|
+
export function encodeServerChallenge(
|
|
326
|
+
serverPublicKey: Uint8Array,
|
|
327
|
+
salt: Uint8Array,
|
|
328
|
+
): Uint8Array {
|
|
329
|
+
const payload = new Uint8Array(serverPublicKey.length + salt.length);
|
|
330
|
+
payload.set(serverPublicKey, 0);
|
|
331
|
+
payload.set(salt, serverPublicKey.length);
|
|
332
|
+
return encodeAuthFrame(payload);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Parse a MSG2 payload (exactly 49 bytes: pubkey 33 + salt 16). */
|
|
336
|
+
export function parseServerChallenge(payload: Uint8Array): {
|
|
337
|
+
serverPublicKey: Uint8Array;
|
|
338
|
+
salt: Uint8Array;
|
|
339
|
+
} {
|
|
340
|
+
if (payload.length !== BTEST_EC_SRP5_CHALLENGE_LEN) {
|
|
341
|
+
throw new CentrsError({
|
|
342
|
+
code: "routeros/btest-protocol",
|
|
343
|
+
summary: `btest EC-SRP5 server challenge must be ${BTEST_EC_SRP5_CHALLENGE_LEN} bytes (got ${payload.length}).`,
|
|
344
|
+
remediation: "The peer sent a bad auth message; confirm the server.",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
serverPublicKey: payload.subarray(0, 33),
|
|
349
|
+
salt: payload.subarray(33, 49),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** MSG3/MSG4: `[confirmation:32]`. */
|
|
354
|
+
export function encodeConfirmation(confirmation: Uint8Array): Uint8Array {
|
|
355
|
+
return encodeAuthFrame(confirmation);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Status message (12 bytes, protocol.rs StatusMessage) ─────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Encode the 12-byte status message: `[0x07][0x80|cpu][0][0][seq u32 LE][bytes
|
|
362
|
+
* u32 LE]`. MikroTik sets the high bit on the CPU byte; `seq` and the received
|
|
363
|
+
* byte counter are both **little-endian**.
|
|
364
|
+
*/
|
|
365
|
+
export function encodeStatus(
|
|
366
|
+
seq: number,
|
|
367
|
+
bytesReceived: number,
|
|
368
|
+
cpuLoad = 0,
|
|
369
|
+
): Uint8Array {
|
|
370
|
+
assertField(seq, U32_MAX, "status seq");
|
|
371
|
+
assertField(bytesReceived, U32_MAX, "status bytes-received");
|
|
372
|
+
assertField(cpuLoad, 100, "cpu-load");
|
|
373
|
+
const buf = new Uint8Array(BTEST_STATUS_MSG_SIZE);
|
|
374
|
+
const view = new DataView(buf.buffer);
|
|
375
|
+
buf[0] = BTEST_STATUS_MSG_TYPE;
|
|
376
|
+
buf[1] = 0x80 | (cpuLoad & 0x7f); // MikroTik high-bit CPU encoding
|
|
377
|
+
// bytes 2-3 stay zero
|
|
378
|
+
view.setUint32(4, seq, true); // little-endian seq
|
|
379
|
+
view.setUint32(8, bytesReceived, true); // little-endian byte counter
|
|
380
|
+
return buf;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Decode a 12-byte status message (tolerates RouterOS's no-high-bit CPU byte). */
|
|
384
|
+
export function decodeStatus(buf: Uint8Array): BtestStatus {
|
|
385
|
+
if (buf.length < BTEST_STATUS_MSG_SIZE) {
|
|
386
|
+
throw new CentrsError({
|
|
387
|
+
code: "routeros/btest-protocol",
|
|
388
|
+
summary: `btest status must be ${BTEST_STATUS_MSG_SIZE} bytes (got ${buf.length}).`,
|
|
389
|
+
remediation:
|
|
390
|
+
"Ignore the stray datagram; this is not a btest status message.",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
394
|
+
return {
|
|
395
|
+
cpuLoad: Math.min((buf[1] as number) & 0x7f, 100),
|
|
396
|
+
seq: view.getUint32(4, true),
|
|
397
|
+
bytesReceived: view.getUint32(8, true),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── UDP data packet: [seq:u32 BE][payload] ───────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/** Build a UDP data packet of `totalSize` bytes (≥4) with the given sequence number. */
|
|
404
|
+
export function encodeUdpPacket(
|
|
405
|
+
seq: number,
|
|
406
|
+
totalSize: number,
|
|
407
|
+
randomData = false,
|
|
408
|
+
): Uint8Array {
|
|
409
|
+
assertField(seq, U32_MAX, "udp seq");
|
|
410
|
+
if (!Number.isInteger(totalSize) || totalSize < 4) {
|
|
411
|
+
throw new CentrsError({
|
|
412
|
+
code: "validation/option",
|
|
413
|
+
summary: `btest UDP packet size must be ≥ 4 (got ${totalSize}).`,
|
|
414
|
+
remediation:
|
|
415
|
+
"Use a tx-size of at least 4 bytes (RouterOS allows 28..64000).",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const buf = randomData
|
|
419
|
+
? crypto.getRandomValues(new Uint8Array(totalSize))
|
|
420
|
+
: new Uint8Array(totalSize);
|
|
421
|
+
new DataView(buf.buffer, buf.byteOffset, buf.byteLength).setUint32(
|
|
422
|
+
0,
|
|
423
|
+
seq,
|
|
424
|
+
false,
|
|
425
|
+
);
|
|
426
|
+
return buf;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Read the big-endian sequence number from a UDP data packet. */
|
|
430
|
+
export function readUdpSequence(buf: Uint8Array): number {
|
|
431
|
+
if (buf.length < 4) {
|
|
432
|
+
throw new CentrsError({
|
|
433
|
+
code: "routeros/btest-protocol",
|
|
434
|
+
summary: "btest UDP packet shorter than its 4-byte sequence header.",
|
|
435
|
+
remediation: "Drop the malformed datagram.",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(
|
|
439
|
+
0,
|
|
440
|
+
false,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Client UDP data port for a given server-assigned port. */
|
|
445
|
+
export function clientUdpPort(serverUdpPort: number): number {
|
|
446
|
+
return serverUdpPort + BTEST_UDP_CLIENT_OFFSET;
|
|
447
|
+
}
|