@tikoci/centrs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,1299 @@
1
+ /**
2
+ * MikroTik MAC-Telnet protocol (Layer-2 execute/terminal path).
3
+ *
4
+ * Transport-side base for the `mac-telnet` protocol id. Like the native API
5
+ * module, this is split into a pure **codec** and a **session** state machine
6
+ * driven over an injectable datagram sink, so the wire format and handshake
7
+ * can be unit-tested without an L2 network.
8
+ *
9
+ * Wire format grounded against Håkon Nessjøen's reference `mactelnet`
10
+ * implementation (`haakonnessjoen/MAC-Telnet`: `protocol.c/.h`, `mactelnet.c`,
11
+ * `mactelnetd.c`). Transport is UDP port 20561, but the protocol carries its
12
+ * own 6+6 byte source/destination MAC addressing inside every packet header
13
+ * (independent of the outer L2/UDP delivery).
14
+ *
15
+ * Both auth modes are supported. The session offers MTWEI (EC-SRP, the modern
16
+ * default — see `mtwei.ts`) in its BEGINAUTH and computes the 32-byte proof when
17
+ * the device replies with a 49-byte PASSSALT; it falls back to classic MD5 when
18
+ * the device offers a 16-byte salt (legacy gear). Verified on CHR 7.23: stock
19
+ * RouterOS rejects MD5 and requires MTWEI. END_AUTH alone does not mean success
20
+ * — a failed login also sends END_AUTH, then a "Login failed" message + END — so
21
+ * success is confirmed only when real terminal output arrives.
22
+ */
23
+
24
+ import { createHash, randomBytes, randomInt } from "node:crypto";
25
+ import { createSocket } from "node:dgram";
26
+ import { readFileSync } from "node:fs";
27
+ import { networkInterfaces } from "node:os";
28
+ import { CentrsError } from "../errors.ts";
29
+ import {
30
+ MTWEI_PUBKEY_LEN,
31
+ type MtweiKeypair,
32
+ mtweiDocrypto,
33
+ mtweiId,
34
+ mtweiKeygen,
35
+ mtweiOfferValue,
36
+ } from "./mtwei.ts";
37
+
38
+ /** UDP port MAC-Telnet listens on. */
39
+ export const MAC_TELNET_PORT = 20561;
40
+ /** Fixed MT header length for SESSIONSTART/DATA/ACK/END packets. */
41
+ export const MAC_TELNET_HEADER_LEN = 22;
42
+ /** Control-block header length: 4-byte magic + 1-byte type + 4-byte length. */
43
+ export const MAC_TELNET_CONTROL_HEADER_LEN = 9;
44
+ /** Control-block magic bytes: `56 34 12 FF`. */
45
+ export const MAC_TELNET_CONTROL_MAGIC = Uint8Array.of(0x56, 0x34, 0x12, 0xff);
46
+ /** Client type identifier sent in every header (`00 15`). */
47
+ export const MAC_TELNET_CLIENT_TYPE = Uint8Array.of(0x00, 0x15);
48
+
49
+ /**
50
+ * Retransmission backoff (ms) for an unacknowledged outbound frame, from the
51
+ * reference `mactelnet` client (`retransmit_intervals`). Up to 9 tries; UDP
52
+ * gives no delivery guarantee, so the protocol layers ACK-by-byte-counter +
53
+ * timed retransmission on top. Driven by {@link MacTelnetSession.tick}.
54
+ */
55
+ export const MAC_TELNET_RETRANSMIT_SCHEDULE_MS: readonly number[] = [
56
+ 15, 20, 30, 50, 90, 170, 330, 660, 1000,
57
+ ];
58
+ /**
59
+ * Idle window before an empty-ACK keepalive is sent. The reference client sends
60
+ * one after ~10s of inactivity and the server drops a session after ~15s
61
+ * (`MT_CONNECTION_TIMEOUT`), so keep it under that ceiling.
62
+ */
63
+ export const MAC_TELNET_KEEPALIVE_IDLE_MS = 8000;
64
+
65
+ /** MT packet types (`enum mt_ptype`). */
66
+ export const MacTelnetPacketType = {
67
+ sessionStart: 0,
68
+ data: 1,
69
+ ack: 2,
70
+ ping: 4,
71
+ pong: 5,
72
+ end: 255,
73
+ } as const;
74
+ export type MacTelnetPacketType =
75
+ (typeof MacTelnetPacketType)[keyof typeof MacTelnetPacketType];
76
+
77
+ /** MT control-packet types (`enum mt_cptype`). */
78
+ export const MacTelnetControlType = {
79
+ beginAuth: 0,
80
+ passwordSalt: 1,
81
+ password: 2,
82
+ username: 3,
83
+ terminalType: 4,
84
+ terminalWidth: 5,
85
+ terminalHeight: 6,
86
+ packetError: 7,
87
+ endAuth: 9,
88
+ } as const;
89
+ export type MacTelnetControlType =
90
+ (typeof MacTelnetControlType)[keyof typeof MacTelnetControlType];
91
+
92
+ /** A MAC address as six octets. */
93
+ export type MacAddress = Uint8Array;
94
+
95
+ /** Parse `aa:bb:cc:dd:ee:ff` (or `-`/`.` separated) into six octets. */
96
+ export function parseMac(value: string): MacAddress {
97
+ const parts = value.trim().split(/[:\-.]/);
98
+ if (parts.length !== 6) {
99
+ throw new CentrsError({
100
+ code: "input/mac-address",
101
+ summary: `"${value}" is not a 6-octet MAC address.`,
102
+ remediation: "Provide a MAC like aa:bb:cc:dd:ee:ff.",
103
+ });
104
+ }
105
+ const octets = new Uint8Array(6);
106
+ for (let index = 0; index < 6; index += 1) {
107
+ const octet = Number.parseInt(parts[index] as string, 16);
108
+ if (!Number.isInteger(octet) || octet < 0 || octet > 0xff) {
109
+ throw new CentrsError({
110
+ code: "input/mac-address",
111
+ summary: `"${value}" has an invalid octet "${parts[index]}".`,
112
+ remediation: "Each octet must be a two-digit hex value (00–ff).",
113
+ });
114
+ }
115
+ octets[index] = octet;
116
+ }
117
+ return octets;
118
+ }
119
+
120
+ /** Format six octets as `aa:bb:cc:dd:ee:ff`. */
121
+ export function formatMac(mac: MacAddress): string {
122
+ return [...mac].map((octet) => octet.toString(16).padStart(2, "0")).join(":");
123
+ }
124
+
125
+ function macEquals(a: MacAddress, b: MacAddress): boolean {
126
+ if (a.length !== b.length) {
127
+ return false;
128
+ }
129
+ for (let index = 0; index < a.length; index += 1) {
130
+ if (a[index] !== b[index]) {
131
+ return false;
132
+ }
133
+ }
134
+ return true;
135
+ }
136
+
137
+ export interface MacTelnetHeader {
138
+ version: number;
139
+ type: number;
140
+ sourceMac: MacAddress;
141
+ destinationMac: MacAddress;
142
+ sessionKey: number;
143
+ counter: number;
144
+ }
145
+
146
+ export interface EncodeHeaderOptions {
147
+ type: number;
148
+ sourceMac: MacAddress;
149
+ destinationMac: MacAddress;
150
+ sessionKey: number;
151
+ counter: number;
152
+ /** Direction flag. Client→server (default) puts the session key at offset 14. */
153
+ fromServer?: boolean;
154
+ }
155
+
156
+ /**
157
+ * Encode a 22-byte MT header. The session-key and client-type fields swap
158
+ * positions by direction, mirroring `init_packet`'s `mt_direction_fromserver`.
159
+ */
160
+ export function encodeHeader(options: EncodeHeaderOptions): Uint8Array {
161
+ const data = new Uint8Array(MAC_TELNET_HEADER_LEN);
162
+ const view = new DataView(data.buffer);
163
+ data[0] = 1;
164
+ data[1] = options.type;
165
+ data.set(options.sourceMac, 2);
166
+ data.set(options.destinationMac, 8);
167
+ const sessionKeyOffset = options.fromServer ? 16 : 14;
168
+ const clientTypeOffset = options.fromServer ? 14 : 16;
169
+ view.setUint16(sessionKeyOffset, options.sessionKey & 0xffff, false);
170
+ data.set(MAC_TELNET_CLIENT_TYPE, clientTypeOffset);
171
+ view.setUint32(18, options.counter >>> 0, false);
172
+ return data;
173
+ }
174
+
175
+ /** Decode a 22-byte MT header. Defaults to decoding a server→client packet. */
176
+ export function decodeHeader(
177
+ bytes: Uint8Array,
178
+ options: { fromServer?: boolean } = {},
179
+ ): MacTelnetHeader {
180
+ if (bytes.length < MAC_TELNET_HEADER_LEN) {
181
+ throw new CentrsError({
182
+ code: "routeros/mac-telnet-protocol",
183
+ summary: `MAC-Telnet packet is too short (${bytes.length} bytes, need at least ${MAC_TELNET_HEADER_LEN}).`,
184
+ remediation:
185
+ "The datagram is truncated or not MAC-Telnet; confirm the source.",
186
+ });
187
+ }
188
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
189
+ const fromServer = options.fromServer ?? true;
190
+ const sessionKeyOffset = fromServer ? 16 : 14;
191
+ return {
192
+ version: bytes[0] as number,
193
+ type: bytes[1] as number,
194
+ sourceMac: bytes.slice(2, 8),
195
+ destinationMac: bytes.slice(8, 14),
196
+ sessionKey: view.getUint16(sessionKeyOffset, false),
197
+ counter: view.getUint32(18, false),
198
+ };
199
+ }
200
+
201
+ /** Encode one control block: magic + type + big-endian length + value. */
202
+ export function encodeControlBlock(
203
+ type: MacTelnetControlType,
204
+ value: Uint8Array = new Uint8Array(0),
205
+ ): Uint8Array {
206
+ const block = new Uint8Array(MAC_TELNET_CONTROL_HEADER_LEN + value.length);
207
+ block.set(MAC_TELNET_CONTROL_MAGIC, 0);
208
+ block[4] = type;
209
+ new DataView(block.buffer).setUint32(5, value.length >>> 0, false);
210
+ block.set(value, MAC_TELNET_CONTROL_HEADER_LEN);
211
+ return block;
212
+ }
213
+
214
+ export interface ParsedControlBlock {
215
+ type: MacTelnetControlType | "plaindata";
216
+ value: Uint8Array;
217
+ }
218
+
219
+ /**
220
+ * Parse the payload of a DATA packet into control blocks. Bytes that do not
221
+ * start with the control magic are returned as a single `plaindata` block,
222
+ * matching the reference `parse_control_packet` fallback.
223
+ */
224
+ export function parseControlBlocks(payload: Uint8Array): ParsedControlBlock[] {
225
+ const blocks: ParsedControlBlock[] = [];
226
+ let offset = 0;
227
+ while (offset < payload.length) {
228
+ const remaining = payload.length - offset;
229
+ const hasMagic =
230
+ remaining >= MAC_TELNET_CONTROL_HEADER_LEN &&
231
+ payload[offset] === MAC_TELNET_CONTROL_MAGIC[0] &&
232
+ payload[offset + 1] === MAC_TELNET_CONTROL_MAGIC[1] &&
233
+ payload[offset + 2] === MAC_TELNET_CONTROL_MAGIC[2] &&
234
+ payload[offset + 3] === MAC_TELNET_CONTROL_MAGIC[3];
235
+ if (!hasMagic) {
236
+ blocks.push({ type: "plaindata", value: payload.slice(offset) });
237
+ break;
238
+ }
239
+ const type = payload[offset + 4] as MacTelnetControlType;
240
+ const length = new DataView(
241
+ payload.buffer,
242
+ payload.byteOffset + offset + 5,
243
+ 4,
244
+ ).getUint32(0, false);
245
+ const valueStart = offset + MAC_TELNET_CONTROL_HEADER_LEN;
246
+ const valueEnd = valueStart + length;
247
+ if (valueEnd > payload.length) {
248
+ throw new CentrsError({
249
+ code: "routeros/mac-telnet-protocol",
250
+ summary: `MAC-Telnet control block claims ${length} bytes but only ${payload.length - valueStart} remain.`,
251
+ remediation:
252
+ "The datagram is corrupt or truncated; confirm the source.",
253
+ });
254
+ }
255
+ blocks.push({ type, value: payload.slice(valueStart, valueEnd) });
256
+ offset = valueEnd;
257
+ }
258
+ return blocks;
259
+ }
260
+
261
+ /**
262
+ * Compute the 17-byte MAC-Telnet password value:
263
+ * `0x00 || MD5(0x00 || password || salt)`.
264
+ */
265
+ export function macTelnetPasswordHash(
266
+ password: string,
267
+ salt: Uint8Array,
268
+ ): Uint8Array {
269
+ const digest = createHash("md5")
270
+ .update(Buffer.from([0]))
271
+ .update(Buffer.from(password, "utf8"))
272
+ .update(salt)
273
+ .digest();
274
+ const out = new Uint8Array(17);
275
+ out[0] = 0x00;
276
+ out.set(digest, 1);
277
+ return out;
278
+ }
279
+
280
+ /** Encode a 2-byte little-endian terminal dimension (width/height). */
281
+ export function encodeTerminalDimension(value: number): Uint8Array {
282
+ const out = new Uint8Array(2);
283
+ new DataView(out.buffer).setUint16(0, value & 0xffff, true);
284
+ return out;
285
+ }
286
+
287
+ function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
288
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
289
+ const out = new Uint8Array(total);
290
+ let cursor = 0;
291
+ for (const part of parts) {
292
+ out.set(part, cursor);
293
+ cursor += part.length;
294
+ }
295
+ return out;
296
+ }
297
+
298
+ export interface BuildPacketOptions {
299
+ type: number;
300
+ sourceMac: MacAddress;
301
+ destinationMac: MacAddress;
302
+ sessionKey: number;
303
+ counter: number;
304
+ payload?: Uint8Array;
305
+ }
306
+
307
+ /** Build a complete client→server packet (header + optional payload). */
308
+ export function buildPacket(options: BuildPacketOptions): Uint8Array {
309
+ const header = encodeHeader({
310
+ type: options.type,
311
+ sourceMac: options.sourceMac,
312
+ destinationMac: options.destinationMac,
313
+ sessionKey: options.sessionKey,
314
+ counter: options.counter,
315
+ });
316
+ if (!options.payload || options.payload.length === 0) {
317
+ return header;
318
+ }
319
+ return concatBytes([header, options.payload]);
320
+ }
321
+
322
+ /** Datagram sink the session writes encoded packets to. */
323
+ export interface MacTelnetDatagramSink {
324
+ /** Send a UDP datagram. */
325
+ send(bytes: Uint8Array): void;
326
+ /** Close the underlying socket. */
327
+ close(): void;
328
+ }
329
+
330
+ /** A datagram sink that also delivers inbound datagrams to a handler. */
331
+ export interface MacTelnetTransport extends MacTelnetDatagramSink {
332
+ /** Register the inbound-datagram handler (e.g. wired to a session). */
333
+ onMessage(handler: (bytes: Uint8Array) => void): void;
334
+ /** Resolve once the socket is bound and ready to send. */
335
+ ready(): Promise<void>;
336
+ }
337
+
338
+ /**
339
+ * Production datagram transport: a UDP socket that sends each MAC-Telnet packet
340
+ * to `host:port` and forwards inbound datagrams to the registered handler.
341
+ *
342
+ * MAC-Telnet's addressing is in-packet (the 6+6 MACs), so UDP delivery is just
343
+ * the carrier: callers pass `host` as an L2 broadcast address (chosen upstream
344
+ * by {@link resolveMacTelnetRoute} — this function applies no default) and the
345
+ * device claims the session by matching the in-packet destination MAC. **Real-device
346
+ * delivery facts (verified on RB1100AHx4 / RouterOS 7.24beta1 over the real
347
+ * UDP/L2 path):** the device answers only a *broadcast* delivery — a unicast
348
+ * datagram addressed straight to it gets no reply — and the reply is itself an
349
+ * L2 broadcast, so the socket binds `0.0.0.0` (any local address) rather than a
350
+ * specific egress IP; binding a single local IP would miss the broadcast reply.
351
+ * The integration harness instead points `host`/`port` at its loopback L2
352
+ * bridge, exercising this exact transport.
353
+ */
354
+ export function createUdpMacTelnetTransport(options: {
355
+ host: string;
356
+ port: number;
357
+ /** Enable `SO_BROADCAST` (needed when `host` is a broadcast address). */
358
+ broadcast?: boolean;
359
+ }): MacTelnetTransport {
360
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
361
+ let handler: ((bytes: Uint8Array) => void) | undefined;
362
+ socket.on("message", (message: Buffer) => {
363
+ handler?.(new Uint8Array(message));
364
+ });
365
+ const readyPromise = new Promise<void>((resolve, reject) => {
366
+ socket.once("error", reject);
367
+ // Bind 0.0.0.0 (not the egress IP): the device's reply is an L2 broadcast.
368
+ socket.bind(0, () => {
369
+ if (options.broadcast) {
370
+ try {
371
+ socket.setBroadcast(true);
372
+ } catch {
373
+ /* not all platforms allow it on an unbound family; non-fatal */
374
+ }
375
+ }
376
+ resolve();
377
+ });
378
+ });
379
+ return {
380
+ send(bytes) {
381
+ socket.send(bytes, options.port, options.host);
382
+ },
383
+ close() {
384
+ try {
385
+ socket.close();
386
+ } catch {
387
+ /* already closed */
388
+ }
389
+ },
390
+ onMessage(next) {
391
+ handler = next;
392
+ },
393
+ ready() {
394
+ return readyPromise;
395
+ },
396
+ };
397
+ }
398
+
399
+ function isZeroMac(mac: MacAddress): boolean {
400
+ return mac.every((octet) => octet === 0);
401
+ }
402
+
403
+ function tryParseMac(value: string | undefined): MacAddress | undefined {
404
+ if (!value) {
405
+ return undefined;
406
+ }
407
+ try {
408
+ return parseMac(value);
409
+ } catch {
410
+ return undefined;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Read an interface's real hardware MAC from the OS when {@link networkInterfaces}
416
+ * reports an all-zero MAC. Some virtual NICs do this — notably ZeroTier's `feth*`
417
+ * taps on macOS — yet RouterOS needs the *real* MAC as the in-packet source. Linux
418
+ * exposes it at `/sys/class/net/<name>/address`; macOS/BSD print it on the `ether`
419
+ * line of `ifconfig <name>`. Returns `undefined` on any failure.
420
+ */
421
+ function hardwareMacOf(name: string): MacAddress | undefined {
422
+ try {
423
+ const sys = readFileSync(`/sys/class/net/${name}/address`, "utf8").trim();
424
+ const mac = tryParseMac(sys);
425
+ if (mac && !isZeroMac(mac)) {
426
+ return mac;
427
+ }
428
+ } catch {
429
+ /* not Linux / no sysfs entry */
430
+ }
431
+ try {
432
+ const result = Bun.spawnSync(["ifconfig", name]);
433
+ const text = new TextDecoder().decode(result.stdout);
434
+ const match = text.match(/\bether\s+([0-9a-f:]{17})\b/i);
435
+ const mac = tryParseMac(match?.[1]);
436
+ if (mac && !isZeroMac(mac)) {
437
+ return mac;
438
+ }
439
+ } catch {
440
+ /* no ifconfig on PATH */
441
+ }
442
+ return undefined;
443
+ }
444
+
445
+ function ipv4ToInt(addr: string): number | undefined {
446
+ const parts = addr.split(".");
447
+ if (parts.length !== 4) {
448
+ return undefined;
449
+ }
450
+ let value = 0;
451
+ for (const part of parts) {
452
+ const octet = Number(part);
453
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
454
+ return undefined;
455
+ }
456
+ value = (value << 8) | octet;
457
+ }
458
+ return value >>> 0;
459
+ }
460
+
461
+ function intToIpv4(value: number): string {
462
+ return [24, 16, 8, 0].map((shift) => (value >>> shift) & 0xff).join(".");
463
+ }
464
+
465
+ /** The IPv4 subnet-directed broadcast for `address`/`netmask` (e.g. `172.29.8.255`). */
466
+ export function directedBroadcast(
467
+ address: string,
468
+ netmask: string,
469
+ ): string | undefined {
470
+ const addr = ipv4ToInt(address);
471
+ const mask = ipv4ToInt(netmask);
472
+ if (addr === undefined || mask === undefined) {
473
+ return undefined;
474
+ }
475
+ return intToIpv4(((addr & mask) | (~mask >>> 0)) >>> 0);
476
+ }
477
+
478
+ /** A local interface usable for MAC-Telnet broadcast: real MAC + directed broadcast. */
479
+ export interface BroadcastInterface {
480
+ name: string;
481
+ address: string;
482
+ broadcast: string;
483
+ mac: MacAddress;
484
+ }
485
+
486
+ /**
487
+ * Enumerate up, non-internal IPv4 interfaces that can carry a MAC-Telnet broadcast,
488
+ * each with its **real** MAC (via {@link hardwareMacOf} when the OS reports zeros)
489
+ * and subnet-directed broadcast. The directed broadcast — not the limited
490
+ * `255.255.255.255` — is what reaches a device on a non-default-route NIC: macOS
491
+ * sends the limited broadcast only out the default route, whereas a directed
492
+ * broadcast is steered to its interface by the routing table.
493
+ */
494
+ export function listBroadcastInterfaces(): BroadcastInterface[] {
495
+ const out: BroadcastInterface[] = [];
496
+ for (const [name, infos] of Object.entries(networkInterfaces())) {
497
+ for (const info of infos ?? []) {
498
+ if (info.family !== "IPv4" || info.internal) {
499
+ continue;
500
+ }
501
+ let mac = tryParseMac(info.mac);
502
+ if (!mac || isZeroMac(mac)) {
503
+ mac = hardwareMacOf(name);
504
+ }
505
+ if (!mac || isZeroMac(mac)) {
506
+ continue;
507
+ }
508
+ const broadcast = directedBroadcast(info.address, info.netmask);
509
+ if (!broadcast) {
510
+ continue;
511
+ }
512
+ out.push({ name, address: info.address, broadcast, mac });
513
+ }
514
+ }
515
+ return out;
516
+ }
517
+
518
+ /** The local source address the OS would pick to reach `host:port` (sends nothing). */
519
+ async function egressAddressFor(
520
+ host: string,
521
+ port: number,
522
+ ): Promise<string | undefined> {
523
+ return await new Promise<string | undefined>((resolve) => {
524
+ const probe = createSocket("udp4");
525
+ const done = (addr?: string): void => {
526
+ try {
527
+ probe.close();
528
+ } catch {
529
+ /* already closed */
530
+ }
531
+ resolve(addr);
532
+ };
533
+ probe.on("error", () => done(undefined));
534
+ try {
535
+ // `connect` only fixes the peer + selects a route; it sends no packet.
536
+ probe.connect(port, host, () => {
537
+ try {
538
+ done(probe.address().address);
539
+ } catch {
540
+ done(undefined);
541
+ }
542
+ });
543
+ } catch {
544
+ done(undefined);
545
+ }
546
+ });
547
+ }
548
+
549
+ /**
550
+ * Resolve the **real MAC of the interface that egresses toward `host:port`**, for
551
+ * use as the in-packet source MAC.
552
+ *
553
+ * RouterOS's mac-server only replies to a SESSIONSTART whose in-packet source MAC
554
+ * is the sending interface's real MAC — a synthetic/locally-administered MAC gets
555
+ * no reply at all (verified on RB1100AHx4 / RouterOS 7.24beta1 over the real
556
+ * UDP/L2 broadcast path; the reference `mactelnet` likewise uses each interface's
557
+ * own MAC). Returns `undefined` when no interface matches the egress address, so
558
+ * the caller can fall back to a synthetic source MAC.
559
+ */
560
+ export async function resolveEgressMac(
561
+ host: string,
562
+ port: number,
563
+ ): Promise<MacAddress | undefined> {
564
+ const localAddr = await egressAddressFor(host, port);
565
+ if (!localAddr) {
566
+ return undefined;
567
+ }
568
+ for (const ifc of listBroadcastInterfaces()) {
569
+ if (ifc.address === localAddr) {
570
+ return ifc.mac;
571
+ }
572
+ }
573
+ return undefined;
574
+ }
575
+
576
+ /** Which interface (source MAC + delivery host) reaches a target over MAC-Telnet. */
577
+ export interface MacTelnetRoute {
578
+ sourceMac: MacAddress;
579
+ host: string;
580
+ }
581
+
582
+ /**
583
+ * Find which local interface can reach `destinationMac` over MAC-Telnet by
584
+ * spraying a SESSIONSTART to **every interface's directed broadcast** (each with
585
+ * that interface's real MAC, all sharing one session key) and matching the
586
+ * device's reply. This is how `execute <mac>` reaches a device wherever it lives —
587
+ * the default-route LAN, a ZeroTier tap, etc. — without the caller naming an
588
+ * interface, mirroring how the reference client and WinBox probe every NIC.
589
+ *
590
+ * The reply is the device's broadcast ACK; its in-packet *destination* MAC tells
591
+ * us which interface's source MAC the device accepted, and that interface's
592
+ * directed broadcast is the winning delivery host. Resolves `undefined` when no
593
+ * interface answers within `timeoutMs` (caller falls back to single-egress).
594
+ */
595
+ export async function discoverMacTelnetRoute(opts: {
596
+ destinationMac: MacAddress;
597
+ port: number;
598
+ timeoutMs: number;
599
+ sessionKey?: number;
600
+ }): Promise<MacTelnetRoute | undefined> {
601
+ const interfaces = listBroadcastInterfaces();
602
+ if (interfaces.length === 0) {
603
+ return undefined;
604
+ }
605
+ const sessionKey = opts.sessionKey ?? randomInt(0x10000);
606
+ return await new Promise<MacTelnetRoute | undefined>((resolve) => {
607
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
608
+ let settled = false;
609
+ const timers: ReturnType<typeof setTimeout>[] = [];
610
+ const finish = (route?: MacTelnetRoute): void => {
611
+ if (settled) {
612
+ return;
613
+ }
614
+ settled = true;
615
+ for (const t of timers) {
616
+ clearTimeout(t);
617
+ }
618
+ try {
619
+ socket.close();
620
+ } catch {
621
+ /* already closed */
622
+ }
623
+ resolve(route);
624
+ };
625
+ socket.on("error", () => finish(undefined));
626
+ socket.on("message", (message: Buffer) => {
627
+ let header: MacTelnetHeader;
628
+ try {
629
+ header = decodeHeader(new Uint8Array(message), { fromServer: true });
630
+ } catch {
631
+ return; // stray/truncated datagram on the shared segment
632
+ }
633
+ if (header.version !== 1 || header.sessionKey !== sessionKey) {
634
+ return;
635
+ }
636
+ if (
637
+ header.type !== MacTelnetPacketType.ack &&
638
+ header.type !== MacTelnetPacketType.data
639
+ ) {
640
+ return;
641
+ }
642
+ if (!macEquals(header.sourceMac, opts.destinationMac)) {
643
+ return;
644
+ }
645
+ const winner = interfaces.find((ifc) =>
646
+ macEquals(ifc.mac, header.destinationMac),
647
+ );
648
+ if (winner) {
649
+ finish({ sourceMac: winner.mac, host: winner.broadcast });
650
+ }
651
+ });
652
+ const spray = (): void => {
653
+ for (const ifc of interfaces) {
654
+ const packet = buildPacket({
655
+ type: MacTelnetPacketType.sessionStart,
656
+ sourceMac: ifc.mac,
657
+ destinationMac: opts.destinationMac,
658
+ sessionKey,
659
+ counter: 0,
660
+ });
661
+ try {
662
+ socket.send(packet, opts.port, ifc.broadcast);
663
+ } catch {
664
+ /* one interface failing must not abort the spray */
665
+ }
666
+ }
667
+ };
668
+ socket.bind(0, () => {
669
+ try {
670
+ socket.setBroadcast(true);
671
+ } catch {
672
+ /* some platforms forbid it on an unbound family; sends may still work */
673
+ }
674
+ spray();
675
+ // A couple of retransmits cover an early dropped SESSIONSTART (UDP).
676
+ timers.push(setTimeout(spray, 250));
677
+ timers.push(setTimeout(spray, 700));
678
+ timers.push(setTimeout(() => finish(undefined), opts.timeoutMs));
679
+ });
680
+ });
681
+ }
682
+
683
+ /** Resolver default delivery host for mac-telnet; signals "discover the route". */
684
+ export const DEFAULT_MAC_TELNET_BROADCAST = "255.255.255.255";
685
+
686
+ /** A random locally-administered unicast MAC (`02:xx:…`) for the in-packet source. */
687
+ export function randomLocalMac(): MacAddress {
688
+ const octets = new Uint8Array(randomBytes(6));
689
+ octets[0] = ((octets[0] as number) & 0xfe) | 0x02; // locally administered, unicast
690
+ return octets;
691
+ }
692
+
693
+ /** True when `host` is a broadcast delivery address (needs `SO_BROADCAST`). */
694
+ export function isBroadcastHost(host: string): boolean {
695
+ return host === DEFAULT_MAC_TELNET_BROADCAST || host.endsWith(".255");
696
+ }
697
+
698
+ export interface MacTelnetRouteConfig {
699
+ destinationMac: MacAddress;
700
+ host: string;
701
+ port: number;
702
+ timeoutMs: number;
703
+ /** Explicit client MAC; when set it always wins and the host is kept as-is. */
704
+ explicitSourceMac?: MacAddress;
705
+ }
706
+
707
+ /**
708
+ * Decide the in-packet source MAC and UDP delivery host for a mac-telnet session.
709
+ * Shared by the execute adapter and the `terminal` command so the (load-bearing)
710
+ * source-MAC choice cannot diverge between them.
711
+ *
712
+ * - An explicit configured source MAC always wins (keeps the configured host).
713
+ * - The default delivery host (the `255.255.255.255` sentinel) means "find the
714
+ * device": spray every interface's directed broadcast and use the one that
715
+ * answers — the only path that reaches a device on a non-default-route NIC.
716
+ * - An explicit host is honored, using that interface's real egress MAC (falling
717
+ * back to a random locally-administered MAC when the OS reports none).
718
+ */
719
+ export async function resolveMacTelnetRoute(
720
+ config: MacTelnetRouteConfig,
721
+ ): Promise<MacTelnetRoute> {
722
+ if (config.explicitSourceMac) {
723
+ return { sourceMac: config.explicitSourceMac, host: config.host };
724
+ }
725
+ if (config.host === DEFAULT_MAC_TELNET_BROADCAST) {
726
+ const route = await discoverMacTelnetRoute({
727
+ destinationMac: config.destinationMac,
728
+ port: config.port,
729
+ timeoutMs: Math.min(config.timeoutMs, 5_000),
730
+ });
731
+ if (route) {
732
+ return route;
733
+ }
734
+ }
735
+ const sourceMac =
736
+ (await resolveEgressMac(config.host, config.port)) ?? randomLocalMac();
737
+ return { sourceMac, host: config.host };
738
+ }
739
+
740
+ export interface MacTelnetSessionOptions {
741
+ sink: MacTelnetDatagramSink;
742
+ /** Client MAC (the in-payload source address). */
743
+ sourceMac: MacAddress;
744
+ /** Target device MAC (the in-payload destination address). */
745
+ destinationMac: MacAddress;
746
+ username: string;
747
+ password: string;
748
+ /** 16-bit session key. Random when omitted. */
749
+ sessionKey?: number;
750
+ terminalType?: string;
751
+ terminalWidth?: number;
752
+ terminalHeight?: number;
753
+ /**
754
+ * Offer MTWEI (EC-SRP) in BEGINAUTH. Default `true` — required by current
755
+ * RouterOS. Set `false` to force the legacy classic flow (BEGINAUTH only),
756
+ * which yields an MD5 salt on devices that still allow it.
757
+ */
758
+ offerMtwei?: boolean;
759
+ /** Called with terminal output bytes. */
760
+ onData?: (bytes: Uint8Array) => void;
761
+ /** Called once the auth handshake completes and the terminal is ready. */
762
+ onReady?: () => void;
763
+ /** Called when the session ends or fails. */
764
+ onClose?: (error?: CentrsError) => void;
765
+ }
766
+
767
+ type SessionState =
768
+ | "init"
769
+ | "session-start-sent"
770
+ | "auth-begin-sent"
771
+ | "auth-sent"
772
+ | "auth-complete"
773
+ | "ready"
774
+ | "closed";
775
+
776
+ /**
777
+ * MAC-Telnet client session state machine over an injectable datagram sink.
778
+ *
779
+ * Drives the classic MD5 handshake: SESSIONSTART → BEGINAUTH →
780
+ * (server PASSSALT) → PASSWORD/USERNAME/terminal → (server END_AUTH) → ready.
781
+ * Feed inbound datagrams via {@link handlePacket}.
782
+ */
783
+ export class MacTelnetSession {
784
+ private readonly options: MacTelnetSessionOptions;
785
+ private readonly sessionKey: number;
786
+ private state: SessionState = "init";
787
+ private outCounter = 0;
788
+ private lastInCounter: number | null = null;
789
+ private mtweiKeypair: MtweiKeypair | undefined;
790
+ /** Counter last sent in an ACK, re-sent verbatim as the empty-ACK keepalive. */
791
+ private lastAckCounter = 0;
792
+ /** Highest inbound ACK counter seen, used to clear {@link pending}. */
793
+ private maxOutAck = -1;
794
+ /** Last unacked retransmittable outbound frame (SESSIONSTART or DATA). */
795
+ private pending:
796
+ | {
797
+ bytes: Uint8Array;
798
+ ackTarget: number;
799
+ attempts: number;
800
+ elapsedMs: number;
801
+ }
802
+ | undefined;
803
+ /** Set on any send/accepted-recv; consumed by {@link tick} to reset the idle clock. */
804
+ private activitySinceTick = false;
805
+ /** Idle accumulator (ms) for the keepalive, advanced by {@link tick} deltas. */
806
+ private idleMs = 0;
807
+ /** Last `tick` timestamp, for delta accumulation (clockless between ticks). */
808
+ private lastTickMs: number | undefined;
809
+
810
+ constructor(options: MacTelnetSessionOptions) {
811
+ this.options = options;
812
+ this.sessionKey = options.sessionKey ?? randomInt(0x10000);
813
+ }
814
+
815
+ /** Current session key. */
816
+ get key(): number {
817
+ return this.sessionKey;
818
+ }
819
+
820
+ /** Begin the handshake by sending SESSIONSTART. */
821
+ start(): void {
822
+ if (this.state !== "init") {
823
+ return;
824
+ }
825
+ const bytes = this.sendPacket(MacTelnetPacketType.sessionStart);
826
+ // Retransmit SESSIONSTART until any ACK arrives (ackTarget 0).
827
+ this.pending = { bytes, ackTarget: 0, attempts: 0, elapsedMs: 0 };
828
+ this.state = "session-start-sent";
829
+ }
830
+
831
+ /** Feed an inbound datagram (a full UDP payload) into the state machine. */
832
+ handlePacket(bytes: Uint8Array): void {
833
+ if (this.state === "closed") {
834
+ return;
835
+ }
836
+ let header: MacTelnetHeader;
837
+ try {
838
+ header = decodeHeader(bytes, { fromServer: true });
839
+ } catch {
840
+ // A truncated / not-MAC-Telnet datagram on the shared UDP/L2 segment is
841
+ // not our session's problem — drop it rather than tearing the session
842
+ // down. Loss of one of our own frames is recovered by retransmit/timeout.
843
+ return;
844
+ }
845
+
846
+ switch (header.type) {
847
+ case MacTelnetPacketType.ack:
848
+ if (!this.matchesSession(header)) {
849
+ return;
850
+ }
851
+ this.onAck(header);
852
+ return;
853
+ case MacTelnetPacketType.data:
854
+ if (!this.matchesSession(header)) {
855
+ return;
856
+ }
857
+ this.onData(header, bytes);
858
+ return;
859
+ case MacTelnetPacketType.end:
860
+ if (!this.matchesSession(header)) {
861
+ return;
862
+ }
863
+ this.onEnd(header);
864
+ return;
865
+ case MacTelnetPacketType.ping:
866
+ // MAC-Ping (18-byte, zeroed session key) does not carry our session
867
+ // key, so it cannot pass matchesSession; only answer pings addressed
868
+ // to our MAC, and ignore everyone else's on the shared segment.
869
+ if (
870
+ header.version === 1 &&
871
+ macEquals(header.destinationMac, this.options.sourceMac)
872
+ ) {
873
+ this.onPing(header);
874
+ }
875
+ return;
876
+ default:
877
+ return; // pong/unknown: ignore for the client base
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Accept a packet only when it belongs to this session: protocol version 1,
883
+ * matching session key, and MAC addresses that mirror our own (server src =
884
+ * our destination, server dst = our source). Stray or spoofed datagrams on
885
+ * the shared UDP/L2 segment are ignored.
886
+ */
887
+ private matchesSession(header: MacTelnetHeader): boolean {
888
+ return (
889
+ header.version === 1 &&
890
+ header.sessionKey === this.sessionKey &&
891
+ macEquals(header.sourceMac, this.options.destinationMac) &&
892
+ macEquals(header.destinationMac, this.options.sourceMac)
893
+ );
894
+ }
895
+
896
+ private onPing(header: MacTelnetHeader): void {
897
+ // Reply to MAC-Ping probes with PONG. (This is the MAC-Ping liveness
898
+ // tool, not the session keepalive — idle sessions are kept alive by
899
+ // empty ACKs, not PING/PONG.)
900
+ this.activitySinceTick = true;
901
+ this.emit(
902
+ encodeHeader({
903
+ type: MacTelnetPacketType.pong,
904
+ sourceMac: this.options.sourceMac,
905
+ destinationMac: this.options.destinationMac,
906
+ sessionKey: header.sessionKey,
907
+ counter: header.counter,
908
+ }),
909
+ );
910
+ }
911
+
912
+ private onAck(header: MacTelnetHeader): void {
913
+ this.activitySinceTick = true;
914
+ // Clear the pending retransmit once the peer has acknowledged it.
915
+ this.maxOutAck = Math.max(this.maxOutAck, header.counter);
916
+ if (this.pending && this.maxOutAck >= this.pending.ackTarget) {
917
+ this.pending = undefined;
918
+ }
919
+ if (this.state !== "session-start-sent") {
920
+ return;
921
+ }
922
+ const blocks: Uint8Array[] = [
923
+ encodeControlBlock(MacTelnetControlType.beginAuth),
924
+ ];
925
+ if (this.options.offerMtwei !== false) {
926
+ // Advertise MTWEI: the device replies with a 49-byte PASSSALT and
927
+ // expects the EC-SRP proof. Without this, current RouterOS only offers
928
+ // MD5, which it then refuses.
929
+ this.mtweiKeypair = mtweiKeygen();
930
+ blocks.push(
931
+ encodeControlBlock(
932
+ MacTelnetControlType.passwordSalt,
933
+ mtweiOfferValue(this.identityUsername(), this.mtweiKeypair.publicKey),
934
+ ),
935
+ );
936
+ }
937
+ this.sendData(blocks);
938
+ this.state = "auth-begin-sent";
939
+ }
940
+
941
+ /** Login name with any `+console-parameter` suffix stripped (for the SRP id). */
942
+ private identityUsername(): string {
943
+ return this.options.username.split("+", 1)[0] ?? this.options.username;
944
+ }
945
+
946
+ private onData(header: MacTelnetHeader, bytes: Uint8Array): void {
947
+ this.activitySinceTick = true;
948
+ const payload = bytes.subarray(MAC_TELNET_HEADER_LEN);
949
+ // Always ACK, even for retransmitted/non-advancing frames, so the peer
950
+ // stops retransmitting; only deliver the payload when it advances.
951
+ this.acknowledge(header.counter, payload.length);
952
+ if (!this.acceptCounter(header.counter)) {
953
+ return; // duplicate / out-of-window
954
+ }
955
+
956
+ let blocks: ParsedControlBlock[];
957
+ try {
958
+ blocks = parseControlBlocks(payload);
959
+ } catch (error) {
960
+ this.fail(error as CentrsError);
961
+ return;
962
+ }
963
+
964
+ for (const block of blocks) {
965
+ this.handleControlBlock(block);
966
+ }
967
+ }
968
+
969
+ private handleControlBlock(block: ParsedControlBlock): void {
970
+ switch (block.type) {
971
+ case MacTelnetControlType.passwordSalt:
972
+ this.handlePasswordSalt(block.value);
973
+ return;
974
+ case MacTelnetControlType.endAuth:
975
+ if (this.state === "auth-sent") {
976
+ // END_AUTH begins terminal mode but does NOT confirm success: a
977
+ // failed login also sends END_AUTH, then a "Login failed" message
978
+ // and END. Defer onReady until real terminal output arrives.
979
+ this.state = "auth-complete";
980
+ }
981
+ return;
982
+ case "plaindata":
983
+ this.handleTerminalData(block.value);
984
+ return;
985
+ case MacTelnetControlType.packetError:
986
+ this.fail(
987
+ new CentrsError({
988
+ code: "routeros/mac-telnet-error",
989
+ summary: `The device reported a MAC-Telnet error: ${new TextDecoder().decode(block.value)}`,
990
+ remediation:
991
+ "Check the credentials and that MAC-Telnet is enabled on the device.",
992
+ }),
993
+ );
994
+ return;
995
+ default:
996
+ return;
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Terminal-stream bytes (PLAINDATA). In `auth-complete` the first content
1002
+ * decides the login outcome: a "Login failed" message means the device
1003
+ * rejected the credentials; anything else means success → fire onReady and
1004
+ * deliver. Once `ready`, all content flows to onData.
1005
+ */
1006
+ private handleTerminalData(data: Uint8Array): void {
1007
+ if (this.state === "auth-complete") {
1008
+ if (/login failed/i.test(new TextDecoder().decode(data))) {
1009
+ this.fail(
1010
+ new CentrsError({
1011
+ code: "transport/auth-failed",
1012
+ summary: "MAC-Telnet login failed: incorrect username or password.",
1013
+ remediation: "Check the credentials configured for this device.",
1014
+ }),
1015
+ );
1016
+ return;
1017
+ }
1018
+ this.state = "ready";
1019
+ this.options.onReady?.();
1020
+ if (data.length > 0) {
1021
+ this.options.onData?.(data);
1022
+ }
1023
+ return;
1024
+ }
1025
+ if (this.state === "ready" && data.length > 0) {
1026
+ this.options.onData?.(data);
1027
+ }
1028
+ }
1029
+
1030
+ private handlePasswordSalt(salt: Uint8Array): void {
1031
+ if (this.state !== "auth-begin-sent") {
1032
+ return;
1033
+ }
1034
+
1035
+ let passwordValue: Uint8Array;
1036
+ if (salt.length === 16) {
1037
+ // Classic MD5 — legacy devices, or the fallback when MTWEI was not
1038
+ // offered. Current RouterOS offers this salt but refuses the proof.
1039
+ passwordValue = macTelnetPasswordHash(this.options.password, salt);
1040
+ } else if (salt.length === MTWEI_PUBKEY_LEN + 16) {
1041
+ // MTWEI: 33-byte server public key ‖ 16-byte salt → 32-byte EC-SRP proof.
1042
+ if (!this.mtweiKeypair) {
1043
+ this.fail(
1044
+ new CentrsError({
1045
+ code: "routeros/mac-telnet-unsupported-auth",
1046
+ summary:
1047
+ "The device requires MTWEI but this session did not offer it.",
1048
+ remediation:
1049
+ "Leave MTWEI enabled (the default) so the client advertises a public key.",
1050
+ }),
1051
+ );
1052
+ return;
1053
+ }
1054
+ const serverKey = salt.subarray(0, MTWEI_PUBKEY_LEN);
1055
+ const mtweiSalt = salt.subarray(MTWEI_PUBKEY_LEN);
1056
+ const validator = mtweiId(
1057
+ this.identityUsername(),
1058
+ this.options.password,
1059
+ mtweiSalt,
1060
+ );
1061
+ passwordValue = mtweiDocrypto(
1062
+ this.mtweiKeypair.privateKey,
1063
+ serverKey,
1064
+ this.mtweiKeypair.publicKey,
1065
+ validator,
1066
+ );
1067
+ } else {
1068
+ this.fail(
1069
+ new CentrsError({
1070
+ code: "routeros/mac-telnet-unsupported-auth",
1071
+ summary: `The device offered an unsupported MAC-Telnet salt length (${salt.length}).`,
1072
+ remediation:
1073
+ "Expected 16 (MD5) or 49 (MTWEI); confirm the device and RouterOS version.",
1074
+ }),
1075
+ );
1076
+ return;
1077
+ }
1078
+
1079
+ const username = new TextEncoder().encode(this.options.username);
1080
+ const terminalType = new TextEncoder().encode(
1081
+ this.options.terminalType ?? "vt102",
1082
+ );
1083
+ const blocks = [
1084
+ encodeControlBlock(MacTelnetControlType.password, passwordValue),
1085
+ encodeControlBlock(MacTelnetControlType.username, username),
1086
+ encodeControlBlock(MacTelnetControlType.terminalType, terminalType),
1087
+ encodeControlBlock(
1088
+ MacTelnetControlType.terminalWidth,
1089
+ encodeTerminalDimension(this.options.terminalWidth ?? 80),
1090
+ ),
1091
+ encodeControlBlock(
1092
+ MacTelnetControlType.terminalHeight,
1093
+ encodeTerminalDimension(this.options.terminalHeight ?? 24),
1094
+ ),
1095
+ ];
1096
+ this.sendData(blocks);
1097
+ this.state = "auth-sent";
1098
+ }
1099
+
1100
+ private onEnd(header: MacTelnetHeader): void {
1101
+ // Echo END to acknowledge.
1102
+ this.emit(
1103
+ encodeHeader({
1104
+ type: MacTelnetPacketType.end,
1105
+ sourceMac: this.options.sourceMac,
1106
+ destinationMac: this.options.destinationMac,
1107
+ sessionKey: header.sessionKey,
1108
+ counter: 0,
1109
+ }),
1110
+ );
1111
+ if (this.state === "auth-complete") {
1112
+ // Closed right after END_AUTH without any prompt → the login failed.
1113
+ this.fail(
1114
+ new CentrsError({
1115
+ code: "transport/auth-failed",
1116
+ summary:
1117
+ "MAC-Telnet login failed: the device closed the session after authentication.",
1118
+ remediation: "Check the credentials configured for this device.",
1119
+ }),
1120
+ );
1121
+ return;
1122
+ }
1123
+ this.close();
1124
+ }
1125
+
1126
+ /** Send terminal input bytes (only valid once ready). */
1127
+ sendInput(bytes: Uint8Array): void {
1128
+ if (this.state !== "ready") {
1129
+ throw new CentrsError({
1130
+ code: "routeros/mac-telnet-not-ready",
1131
+ summary: "Cannot send input before the MAC-Telnet session is ready.",
1132
+ remediation: "Wait for the onReady callback before sending input.",
1133
+ });
1134
+ }
1135
+ // Terminal data is raw PLAINDATA (no control header).
1136
+ this.sendData([bytes]);
1137
+ }
1138
+
1139
+ /** Send END and close the session. */
1140
+ end(): void {
1141
+ if (this.state === "closed") {
1142
+ return;
1143
+ }
1144
+ this.emit(
1145
+ encodeHeader({
1146
+ type: MacTelnetPacketType.end,
1147
+ sourceMac: this.options.sourceMac,
1148
+ destinationMac: this.options.destinationMac,
1149
+ sessionKey: this.sessionKey,
1150
+ counter: this.outCounter,
1151
+ }),
1152
+ );
1153
+ this.close();
1154
+ }
1155
+
1156
+ private close(error?: CentrsError): void {
1157
+ if (this.state === "closed") {
1158
+ return;
1159
+ }
1160
+ this.state = "closed";
1161
+ this.pending = undefined;
1162
+ this.options.sink.close();
1163
+ this.options.onClose?.(error);
1164
+ }
1165
+
1166
+ private fail(error: CentrsError): void {
1167
+ this.close(error);
1168
+ }
1169
+
1170
+ /**
1171
+ * Fault-tolerant outbound send. A transient transport error (socket already
1172
+ * closed, write-after-end during teardown) must never propagate out of an
1173
+ * inbound-datagram handler and crash the caller's event loop; recovery is the
1174
+ * peer's retransmit or our own timeout. All session sends go through here.
1175
+ */
1176
+ private emit(bytes: Uint8Array): void {
1177
+ try {
1178
+ this.options.sink.send(bytes);
1179
+ } catch {
1180
+ /* transport closing / transient send failure — ignore */
1181
+ }
1182
+ }
1183
+
1184
+ private acceptCounter(counter: number): boolean {
1185
+ if (this.lastInCounter === null) {
1186
+ this.lastInCounter = counter;
1187
+ return true;
1188
+ }
1189
+ if (counter > this.lastInCounter) {
1190
+ this.lastInCounter = counter;
1191
+ return true;
1192
+ }
1193
+ return false;
1194
+ }
1195
+
1196
+ private acknowledge(counter: number, payloadLength: number): void {
1197
+ this.lastAckCounter = (counter + payloadLength) >>> 0;
1198
+ this.activitySinceTick = true;
1199
+ this.emit(
1200
+ encodeHeader({
1201
+ type: MacTelnetPacketType.ack,
1202
+ sourceMac: this.options.sourceMac,
1203
+ destinationMac: this.options.destinationMac,
1204
+ sessionKey: this.sessionKey,
1205
+ counter: this.lastAckCounter,
1206
+ }),
1207
+ );
1208
+ }
1209
+
1210
+ private sendPacket(type: number, payload?: Uint8Array): Uint8Array {
1211
+ const bytes = buildPacket({
1212
+ type,
1213
+ sourceMac: this.options.sourceMac,
1214
+ destinationMac: this.options.destinationMac,
1215
+ sessionKey: this.sessionKey,
1216
+ counter: this.outCounter,
1217
+ payload,
1218
+ });
1219
+ this.activitySinceTick = true;
1220
+ this.emit(bytes);
1221
+ return bytes;
1222
+ }
1223
+
1224
+ private sendData(parts: readonly Uint8Array[]): void {
1225
+ const payload = concatBytes(parts);
1226
+ const bytes = this.sendPacket(MacTelnetPacketType.data, payload);
1227
+ // Control blocks include their 9-byte headers in `payload.length`;
1228
+ // PLAINDATA contributes its raw bytes. Both advance the counter by the
1229
+ // number of payload bytes actually sent.
1230
+ this.outCounter = (this.outCounter + payload.length) >>> 0;
1231
+ // Retransmit this frame until the peer's ACK reaches its end (outCounter).
1232
+ this.pending = {
1233
+ bytes,
1234
+ ackTarget: this.outCounter,
1235
+ attempts: 0,
1236
+ elapsedMs: 0,
1237
+ };
1238
+ }
1239
+
1240
+ /**
1241
+ * Advance the session's internal timers. The owner (e.g. {@link
1242
+ * MacTelnetConsole}) calls this on a short interval with `Date.now()`. It is
1243
+ * the only clocked behavior: it retransmits the last unacknowledged frame on
1244
+ * the reference backoff schedule and emits an empty-ACK keepalive after an idle
1245
+ * window. Unit tests that never call `tick` see a purely reactive session.
1246
+ */
1247
+ tick(nowMs: number): void {
1248
+ if (this.state === "closed") {
1249
+ return;
1250
+ }
1251
+ const delta =
1252
+ this.lastTickMs === undefined ? 0 : Math.max(0, nowMs - this.lastTickMs);
1253
+ this.lastTickMs = nowMs;
1254
+
1255
+ // Keepalive: an empty ACK after an idle window keeps the server from
1256
+ // dropping a held-open or slow session.
1257
+ if (this.activitySinceTick) {
1258
+ this.idleMs = 0;
1259
+ this.activitySinceTick = false;
1260
+ } else {
1261
+ this.idleMs += delta;
1262
+ }
1263
+ if (this.idleMs >= MAC_TELNET_KEEPALIVE_IDLE_MS) {
1264
+ this.sendKeepalive();
1265
+ this.idleMs = 0;
1266
+ }
1267
+
1268
+ // Retransmit the last unacked frame (idempotent: the peer dedupes on the
1269
+ // byte counter, so re-sending a command frame never double-executes it).
1270
+ const pending = this.pending;
1271
+ if (
1272
+ pending &&
1273
+ pending.attempts < MAC_TELNET_RETRANSMIT_SCHEDULE_MS.length
1274
+ ) {
1275
+ pending.elapsedMs += delta;
1276
+ const wait = MAC_TELNET_RETRANSMIT_SCHEDULE_MS[
1277
+ pending.attempts
1278
+ ] as number;
1279
+ if (pending.elapsedMs >= wait) {
1280
+ this.emit(pending.bytes);
1281
+ pending.attempts += 1;
1282
+ pending.elapsedMs = 0;
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ /** Re-send the last ACK as an empty-ACK keepalive (no payload, no counter advance). */
1288
+ private sendKeepalive(): void {
1289
+ this.emit(
1290
+ encodeHeader({
1291
+ type: MacTelnetPacketType.ack,
1292
+ sourceMac: this.options.sourceMac,
1293
+ destinationMac: this.options.destinationMac,
1294
+ sessionKey: this.sessionKey,
1295
+ counter: this.lastAckCounter,
1296
+ }),
1297
+ );
1298
+ }
1299
+ }