@usex/mikrotik-mcp 2.0.0 → 2.2.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/dist/index.js CHANGED
@@ -23,6 +23,10 @@ var DeviceConfigSchema = z.object({
23
23
  privateKey: z.string().optional(),
24
24
  keyPassphrase: z.string().optional(),
25
25
  timeoutMs: z.coerce.number().int().positive().default(1e4),
26
+ mac: z.string().optional(),
27
+ sourceMac: z.string().optional(),
28
+ macHost: z.string().optional(),
29
+ macPort: z.coerce.number().int().positive().optional(),
26
30
  description: z.string().optional()
27
31
  });
28
32
  var S3ConfigSchema = z.object({
@@ -110,7 +114,11 @@ function loadConfig(argv = process.argv.slice(2)) {
110
114
  keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
111
115
  privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
112
116
  keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
113
- timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS")
117
+ timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
118
+ mac: pick("mac", "MIKROTIK_MAC"),
119
+ sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
120
+ macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
121
+ macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
114
122
  };
115
123
  const hasSingle = Object.values(single).some((v) => v !== undefined);
116
124
  const devices = {};
@@ -211,10 +219,6 @@ function getDevice(name) {
211
219
  return dc;
212
220
  }
213
221
 
214
- // src/ssh/client.ts
215
- import { readFileSync as readFileSync2 } from "fs";
216
- import { Client } from "ssh2";
217
-
218
222
  // src/logger.ts
219
223
  import { stderr } from "process";
220
224
  var LEVELS = {
@@ -247,7 +251,1206 @@ var logger = {
247
251
  error: (m) => emit("error", m)
248
252
  };
249
253
 
254
+ // src/mac-telnet/protocol.ts
255
+ import { createHash as createHash2, randomBytes as randomBytes2, randomInt } from "crypto";
256
+ import { createSocket } from "dgram";
257
+ import { readFileSync as readFileSync2 } from "fs";
258
+ import { networkInterfaces } from "os";
259
+
260
+ // src/mac-telnet/ec-srp5.ts
261
+ import { createHash, randomBytes } from "crypto";
262
+ var EC_SRP5_PUBKEY_LEN = 33;
263
+ var P = BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed");
264
+ var A = BigInt("0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144");
265
+ var B = BigInt("0x7b425ed097b425ed097b425ed097b425ed097b425ed097b4260b5e9c7710c864");
266
+ var N = BigInt("0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed");
267
+ var GX = BigInt("0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad245a");
268
+ var GY = BigInt("0x5f51e65e475f794b1fe122d388b72eb36dc2b28192839e4dd6163a5d81312c14");
269
+ var W2M = BigInt("0x555555555555555555555555555555555555555555555555555555555552db9c");
270
+ var M2W = BigInt("0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad2451");
271
+ var SQRT_M1 = modPow(2n, (P - 1n) / 4n, P);
272
+ var G = { x: GX, y: GY };
273
+ function mod(value, m) {
274
+ const r = value % m;
275
+ return r < 0n ? r + m : r;
276
+ }
277
+ function modPow(base, exp, m) {
278
+ let result = 1n;
279
+ let b = mod(base, m);
280
+ let e = exp;
281
+ while (e > 0n) {
282
+ if (e & 1n)
283
+ result = result * b % m;
284
+ b = b * b % m;
285
+ e >>= 1n;
286
+ }
287
+ return result;
288
+ }
289
+ function modInv(value) {
290
+ return modPow(value, P - 2n, P);
291
+ }
292
+ function modSqrt(a) {
293
+ const aa = mod(a, P);
294
+ if (aa === 0n)
295
+ return 0n;
296
+ let x = modPow(aa, (P + 3n) / 8n, P);
297
+ if (x * x % P === aa)
298
+ return x;
299
+ x = mod(x * SQRT_M1, P);
300
+ if (x * x % P === aa)
301
+ return x;
302
+ return null;
303
+ }
304
+ function bytesToBigIntBE(bytes) {
305
+ let value = 0n;
306
+ for (const byte of bytes)
307
+ value = value << 8n | BigInt(byte);
308
+ return value;
309
+ }
310
+ function bigIntToBytesBE(value, length) {
311
+ const out = new Uint8Array(length);
312
+ let v = value;
313
+ for (let i = length - 1;i >= 0; i -= 1) {
314
+ out[i] = Number(v & 0xffn);
315
+ v >>= 8n;
316
+ }
317
+ return out;
318
+ }
319
+ function sha256(...chunks) {
320
+ const hash = createHash("sha256");
321
+ for (const chunk of chunks)
322
+ hash.update(chunk);
323
+ return new Uint8Array(hash.digest());
324
+ }
325
+ function concatBytes(parts) {
326
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
327
+ const out = new Uint8Array(total);
328
+ let cursor = 0;
329
+ for (const part of parts) {
330
+ out.set(part, cursor);
331
+ cursor += part.length;
332
+ }
333
+ return out;
334
+ }
335
+ function pointAdd(p, q) {
336
+ if (p === null)
337
+ return q;
338
+ if (q === null)
339
+ return p;
340
+ if (p.x === q.x) {
341
+ if (mod(p.y + q.y, P) === 0n)
342
+ return null;
343
+ return pointDouble(p);
344
+ }
345
+ const slope = mod((q.y - p.y) * modInv(mod(q.x - p.x, P)), P);
346
+ const x = mod(slope * slope - p.x - q.x, P);
347
+ const y = mod(slope * (p.x - x) - p.y, P);
348
+ return { x, y };
349
+ }
350
+ function pointDouble(p) {
351
+ if (p === null)
352
+ return null;
353
+ if (p.y === 0n)
354
+ return null;
355
+ const slope = mod((3n * p.x * p.x + A) * modInv(mod(2n * p.y, P)), P);
356
+ const x = mod(slope * slope - 2n * p.x, P);
357
+ const y = mod(slope * (p.x - x) - p.y, P);
358
+ return { x, y };
359
+ }
360
+ function scalarMul(scalar, point) {
361
+ let result = null;
362
+ let addend = point;
363
+ let k = scalar;
364
+ while (k > 0n) {
365
+ if (k & 1n)
366
+ result = pointAdd(result, addend);
367
+ addend = pointDouble(addend);
368
+ k >>= 1n;
369
+ }
370
+ return result;
371
+ }
372
+ function liftX(xWeier, parity) {
373
+ const x = mod(xWeier, P);
374
+ const rhs = mod(x * x * x + A * x + B, P);
375
+ const y0 = modSqrt(rhs);
376
+ if (y0 === null)
377
+ return null;
378
+ const y = Number(y0 & 1n) === (parity & 1) ? y0 : mod(-y0, P);
379
+ return { x, y };
380
+ }
381
+ function encodePoint(point) {
382
+ if (point === null) {
383
+ throw new Error("Cannot encode the EC-SRP5 point at infinity.");
384
+ }
385
+ const out = new Uint8Array(EC_SRP5_PUBKEY_LEN);
386
+ out.set(bigIntToBytesBE(mod(point.x + W2M, P), 32), 0);
387
+ out[32] = Number(point.y & 1n);
388
+ return out;
389
+ }
390
+ function decodePoint(key) {
391
+ if (key.length !== EC_SRP5_PUBKEY_LEN) {
392
+ throw new Error(`EC-SRP5 public key must be ${EC_SRP5_PUBKEY_LEN} bytes (got ${key.length}).`);
393
+ }
394
+ const xWeier = mod(bytesToBigIntBE(key.subarray(0, 32)) + M2W, P);
395
+ const point = liftX(xWeier, key[32]);
396
+ if (point === null) {
397
+ throw new Error("EC-SRP5 public key X is not a valid curve point.");
398
+ }
399
+ return point;
400
+ }
401
+ function ecSrp5Keygen(privBytes) {
402
+ const priv = privBytes ? Uint8Array.from(privBytes) : new Uint8Array(randomBytes(32));
403
+ if (priv.length !== 32) {
404
+ throw new Error("EC-SRP5 private key seed must be 32 bytes.");
405
+ }
406
+ priv[0] = priv[0] & 248;
407
+ priv[31] = priv[31] & 127;
408
+ priv[31] = priv[31] | 64;
409
+ const privateKey = bytesToBigIntBE(priv);
410
+ return { privateKey, publicKey: encodePoint(scalarMul(privateKey, G)) };
411
+ }
412
+ function ecSrp5Id(username, password, salt) {
413
+ const inner = sha256(new TextEncoder().encode(`${username}:${password}`));
414
+ return sha256(salt, inner);
415
+ }
416
+ function redp1(montgomeryX, parity) {
417
+ let seed = bytesToBigIntBE(sha256(montgomeryX));
418
+ for (;; ) {
419
+ const candidate = sha256(bigIntToBytesBE(seed, 32));
420
+ const xWeier = mod(bytesToBigIntBE(candidate) + M2W, P);
421
+ const point = liftX(xWeier, parity);
422
+ if (point !== null)
423
+ return point;
424
+ seed = mod(seed + 1n, 1n << 256n);
425
+ }
426
+ }
427
+ function ecSrp5ClientShared(privateKey, serverKey, clientKey, validator) {
428
+ const serverPoint = decodePoint(serverKey);
429
+ const v = bytesToBigIntBE(validator);
430
+ const vG = scalarMul(v, G);
431
+ if (vG === null) {
432
+ throw new Error("EC-SRP5 validator produced the point at infinity.");
433
+ }
434
+ const gamma = redp1(bigIntToBytesBE(mod(vG.x + W2M, P), 32), 1);
435
+ const wB = pointAdd(serverPoint, gamma);
436
+ const j = sha256(clientKey.subarray(0, 32), serverKey.subarray(0, 32));
437
+ const scalar = mod(v * bytesToBigIntBE(j) + privateKey, N);
438
+ const pt = scalarMul(scalar, wB);
439
+ if (pt === null) {
440
+ throw new Error("EC-SRP5 shared point computed as infinity.");
441
+ }
442
+ const z2 = bigIntToBytesBE(mod(pt.x + W2M, P), 32);
443
+ return { j, z: z2 };
444
+ }
445
+ function ecSrp5ClientProof(privateKey, serverKey, clientKey, validator) {
446
+ const { j, z: z2 } = ecSrp5ClientShared(privateKey, serverKey, clientKey, validator);
447
+ return sha256(j, z2);
448
+ }
449
+
450
+ // src/mac-telnet/mtwei.ts
451
+ function mtweiOfferValue(username, publicKey) {
452
+ return concatBytes([new TextEncoder().encode(username), Uint8Array.of(0), publicKey]);
453
+ }
454
+
455
+ // src/mac-telnet/protocol.ts
456
+ var MAC_TELNET_PORT = 20561;
457
+ var MAC_TELNET_HEADER_LEN = 22;
458
+ var MAC_TELNET_CONTROL_HEADER_LEN = 9;
459
+ var MAC_TELNET_CONTROL_MAGIC = Uint8Array.of(86, 52, 18, 255);
460
+ var MAC_TELNET_CLIENT_TYPE = Uint8Array.of(0, 21);
461
+ var MAC_TELNET_RETRANSMIT_SCHEDULE_MS = [
462
+ 15,
463
+ 20,
464
+ 30,
465
+ 50,
466
+ 90,
467
+ 170,
468
+ 330,
469
+ 660,
470
+ 1000
471
+ ];
472
+ var MAC_TELNET_KEEPALIVE_IDLE_MS = 8000;
473
+ var MacTelnetPacketType = {
474
+ sessionStart: 0,
475
+ data: 1,
476
+ ack: 2,
477
+ ping: 4,
478
+ pong: 5,
479
+ end: 255
480
+ };
481
+ var MacTelnetControlType = {
482
+ beginAuth: 0,
483
+ passwordSalt: 1,
484
+ password: 2,
485
+ username: 3,
486
+ terminalType: 4,
487
+ terminalWidth: 5,
488
+ terminalHeight: 6,
489
+ packetError: 7,
490
+ endAuth: 9
491
+ };
492
+ function parseMac(value) {
493
+ const parts = value.trim().split(/[:\-.]/);
494
+ if (parts.length !== 6) {
495
+ throw new Error(`"${value}" is not a 6-octet MAC address. Provide a MAC like aa:bb:cc:dd:ee:ff.`);
496
+ }
497
+ const octets = new Uint8Array(6);
498
+ for (let index = 0;index < 6; index += 1) {
499
+ const octet = Number.parseInt(parts[index], 16);
500
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
501
+ throw new Error(`"${value}" has an invalid octet "${parts[index]}". Each octet must be a two-digit hex value (00\u2013ff).`);
502
+ }
503
+ octets[index] = octet;
504
+ }
505
+ return octets;
506
+ }
507
+ function macEquals(a, b) {
508
+ if (a.length !== b.length)
509
+ return false;
510
+ for (let index = 0;index < a.length; index += 1) {
511
+ if (a[index] !== b[index])
512
+ return false;
513
+ }
514
+ return true;
515
+ }
516
+ function encodeHeader(options) {
517
+ const data = new Uint8Array(MAC_TELNET_HEADER_LEN);
518
+ const view = new DataView(data.buffer);
519
+ data[0] = 1;
520
+ data[1] = options.type;
521
+ data.set(options.sourceMac, 2);
522
+ data.set(options.destinationMac, 8);
523
+ const sessionKeyOffset = options.fromServer ? 16 : 14;
524
+ const clientTypeOffset = options.fromServer ? 14 : 16;
525
+ view.setUint16(sessionKeyOffset, options.sessionKey & 65535, false);
526
+ data.set(MAC_TELNET_CLIENT_TYPE, clientTypeOffset);
527
+ view.setUint32(18, options.counter >>> 0, false);
528
+ return data;
529
+ }
530
+ function decodeHeader(bytes, options = {}) {
531
+ if (bytes.length < MAC_TELNET_HEADER_LEN) {
532
+ throw new Error(`MAC-Telnet packet is too short (${bytes.length} bytes, need at least ${MAC_TELNET_HEADER_LEN}).`);
533
+ }
534
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
535
+ const fromServer = options.fromServer ?? true;
536
+ const sessionKeyOffset = fromServer ? 16 : 14;
537
+ return {
538
+ version: bytes[0],
539
+ type: bytes[1],
540
+ sourceMac: bytes.slice(2, 8),
541
+ destinationMac: bytes.slice(8, 14),
542
+ sessionKey: view.getUint16(sessionKeyOffset, false),
543
+ counter: view.getUint32(18, false)
544
+ };
545
+ }
546
+ function encodeControlBlock(type, value = new Uint8Array(0)) {
547
+ const block = new Uint8Array(MAC_TELNET_CONTROL_HEADER_LEN + value.length);
548
+ block.set(MAC_TELNET_CONTROL_MAGIC, 0);
549
+ block[4] = type;
550
+ new DataView(block.buffer).setUint32(5, value.length >>> 0, false);
551
+ block.set(value, MAC_TELNET_CONTROL_HEADER_LEN);
552
+ return block;
553
+ }
554
+ function parseControlBlocks(payload) {
555
+ const blocks = [];
556
+ let offset = 0;
557
+ while (offset < payload.length) {
558
+ const remaining = payload.length - offset;
559
+ const hasMagic = remaining >= MAC_TELNET_CONTROL_HEADER_LEN && payload[offset] === MAC_TELNET_CONTROL_MAGIC[0] && payload[offset + 1] === MAC_TELNET_CONTROL_MAGIC[1] && payload[offset + 2] === MAC_TELNET_CONTROL_MAGIC[2] && payload[offset + 3] === MAC_TELNET_CONTROL_MAGIC[3];
560
+ if (!hasMagic) {
561
+ blocks.push({ type: "plaindata", value: payload.slice(offset) });
562
+ break;
563
+ }
564
+ const type = payload[offset + 4];
565
+ const length = new DataView(payload.buffer, payload.byteOffset + offset + 5, 4).getUint32(0, false);
566
+ const valueStart = offset + MAC_TELNET_CONTROL_HEADER_LEN;
567
+ const valueEnd = valueStart + length;
568
+ if (valueEnd > payload.length) {
569
+ throw new Error(`MAC-Telnet control block claims ${length} bytes but only ${payload.length - valueStart} remain.`);
570
+ }
571
+ blocks.push({ type, value: payload.slice(valueStart, valueEnd) });
572
+ offset = valueEnd;
573
+ }
574
+ return blocks;
575
+ }
576
+ function macTelnetPasswordHash(password, salt) {
577
+ const digest = createHash2("md5").update(Buffer.from([0])).update(Buffer.from(password, "utf8")).update(salt).digest();
578
+ const out = new Uint8Array(17);
579
+ out[0] = 0;
580
+ out.set(digest, 1);
581
+ return out;
582
+ }
583
+ function encodeTerminalDimension(value) {
584
+ const out = new Uint8Array(2);
585
+ new DataView(out.buffer).setUint16(0, value & 65535, true);
586
+ return out;
587
+ }
588
+ function concatBytes2(parts) {
589
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
590
+ const out = new Uint8Array(total);
591
+ let cursor = 0;
592
+ for (const part of parts) {
593
+ out.set(part, cursor);
594
+ cursor += part.length;
595
+ }
596
+ return out;
597
+ }
598
+ function buildPacket(options) {
599
+ const header = encodeHeader({
600
+ type: options.type,
601
+ sourceMac: options.sourceMac,
602
+ destinationMac: options.destinationMac,
603
+ sessionKey: options.sessionKey,
604
+ counter: options.counter
605
+ });
606
+ if (!options.payload || options.payload.length === 0)
607
+ return header;
608
+ return concatBytes2([header, options.payload]);
609
+ }
610
+ function createUdpMacTelnetTransport(options) {
611
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
612
+ let handler;
613
+ socket.on("message", (message) => {
614
+ handler?.(new Uint8Array(message));
615
+ });
616
+ const readyPromise = new Promise((resolve, reject) => {
617
+ socket.once("error", reject);
618
+ socket.bind(0, () => {
619
+ if (options.broadcast) {
620
+ try {
621
+ socket.setBroadcast(true);
622
+ } catch {}
623
+ }
624
+ resolve();
625
+ });
626
+ });
627
+ return {
628
+ send(bytes) {
629
+ socket.send(bytes, options.port, options.host);
630
+ },
631
+ close() {
632
+ try {
633
+ socket.close();
634
+ } catch {}
635
+ },
636
+ onMessage(next) {
637
+ handler = next;
638
+ },
639
+ ready() {
640
+ return readyPromise;
641
+ }
642
+ };
643
+ }
644
+ function isZeroMac(mac) {
645
+ return mac.every((octet) => octet === 0);
646
+ }
647
+ function tryParseMac(value) {
648
+ if (!value)
649
+ return;
650
+ try {
651
+ return parseMac(value);
652
+ } catch {
653
+ return;
654
+ }
655
+ }
656
+ function hardwareMacOf(name) {
657
+ try {
658
+ const sys = readFileSync2(`/sys/class/net/${name}/address`, "utf8").trim();
659
+ const mac = tryParseMac(sys);
660
+ if (mac && !isZeroMac(mac))
661
+ return mac;
662
+ } catch {}
663
+ return;
664
+ }
665
+ function ipv4ToInt(addr) {
666
+ const parts = addr.split(".");
667
+ if (parts.length !== 4)
668
+ return;
669
+ let value = 0;
670
+ for (const part of parts) {
671
+ const octet = Number(part);
672
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255)
673
+ return;
674
+ value = value << 8 | octet;
675
+ }
676
+ return value >>> 0;
677
+ }
678
+ function intToIpv4(value) {
679
+ return [24, 16, 8, 0].map((shift) => value >>> shift & 255).join(".");
680
+ }
681
+ function directedBroadcast(address, netmask) {
682
+ const addr = ipv4ToInt(address);
683
+ const mask = ipv4ToInt(netmask);
684
+ if (addr === undefined || mask === undefined)
685
+ return;
686
+ return intToIpv4((addr & mask | ~mask >>> 0) >>> 0);
687
+ }
688
+ function listBroadcastInterfaces() {
689
+ const out = [];
690
+ for (const [name, infos] of Object.entries(networkInterfaces())) {
691
+ for (const info of infos ?? []) {
692
+ if (info.family !== "IPv4" || info.internal)
693
+ continue;
694
+ let mac = tryParseMac(info.mac);
695
+ if (!mac || isZeroMac(mac))
696
+ mac = hardwareMacOf(name);
697
+ if (!mac || isZeroMac(mac))
698
+ continue;
699
+ const broadcast = directedBroadcast(info.address, info.netmask);
700
+ if (!broadcast)
701
+ continue;
702
+ out.push({ name, address: info.address, broadcast, mac });
703
+ }
704
+ }
705
+ return out;
706
+ }
707
+ async function egressAddressFor(host, port) {
708
+ return await new Promise((resolve) => {
709
+ const probe = createSocket("udp4");
710
+ const done = (addr) => {
711
+ try {
712
+ probe.close();
713
+ } catch {}
714
+ resolve(addr);
715
+ };
716
+ probe.on("error", () => done(undefined));
717
+ try {
718
+ probe.connect(port, host, () => {
719
+ try {
720
+ done(probe.address().address);
721
+ } catch {
722
+ done(undefined);
723
+ }
724
+ });
725
+ } catch {
726
+ done(undefined);
727
+ }
728
+ });
729
+ }
730
+ async function resolveEgressMac(host, port) {
731
+ const localAddr = await egressAddressFor(host, port);
732
+ if (!localAddr)
733
+ return;
734
+ for (const ifc of listBroadcastInterfaces()) {
735
+ if (ifc.address === localAddr)
736
+ return ifc.mac;
737
+ }
738
+ return;
739
+ }
740
+ async function discoverMacTelnetRoute(opts) {
741
+ const interfaces = listBroadcastInterfaces();
742
+ if (interfaces.length === 0)
743
+ return;
744
+ const sessionKey = opts.sessionKey ?? randomInt(65536);
745
+ return await new Promise((resolve) => {
746
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
747
+ let settled = false;
748
+ const timers = [];
749
+ const finish = (route) => {
750
+ if (settled)
751
+ return;
752
+ settled = true;
753
+ for (const t of timers)
754
+ clearTimeout(t);
755
+ try {
756
+ socket.close();
757
+ } catch {}
758
+ resolve(route);
759
+ };
760
+ socket.on("error", () => finish(undefined));
761
+ socket.on("message", (message) => {
762
+ let header;
763
+ try {
764
+ header = decodeHeader(new Uint8Array(message), { fromServer: true });
765
+ } catch {
766
+ return;
767
+ }
768
+ if (header.version !== 1 || header.sessionKey !== sessionKey)
769
+ return;
770
+ if (header.type !== MacTelnetPacketType.ack && header.type !== MacTelnetPacketType.data) {
771
+ return;
772
+ }
773
+ if (!macEquals(header.sourceMac, opts.destinationMac))
774
+ return;
775
+ const winner = interfaces.find((ifc) => macEquals(ifc.mac, header.destinationMac));
776
+ if (winner)
777
+ finish({ sourceMac: winner.mac, host: winner.broadcast });
778
+ });
779
+ const spray = () => {
780
+ for (const ifc of interfaces) {
781
+ const packet = buildPacket({
782
+ type: MacTelnetPacketType.sessionStart,
783
+ sourceMac: ifc.mac,
784
+ destinationMac: opts.destinationMac,
785
+ sessionKey,
786
+ counter: 0
787
+ });
788
+ try {
789
+ socket.send(packet, opts.port, ifc.broadcast);
790
+ } catch {}
791
+ }
792
+ };
793
+ socket.bind(0, () => {
794
+ try {
795
+ socket.setBroadcast(true);
796
+ } catch {}
797
+ spray();
798
+ timers.push(setTimeout(spray, 250));
799
+ timers.push(setTimeout(spray, 700));
800
+ timers.push(setTimeout(() => finish(undefined), opts.timeoutMs));
801
+ });
802
+ });
803
+ }
804
+ var DEFAULT_MAC_TELNET_BROADCAST = "255.255.255.255";
805
+ function randomLocalMac() {
806
+ const octets = new Uint8Array(randomBytes2(6));
807
+ octets[0] = octets[0] & 254 | 2;
808
+ return octets;
809
+ }
810
+ function isBroadcastHost(host) {
811
+ return host === DEFAULT_MAC_TELNET_BROADCAST || host.endsWith(".255");
812
+ }
813
+ async function resolveMacTelnetRoute(config) {
814
+ if (config.explicitSourceMac) {
815
+ return { sourceMac: config.explicitSourceMac, host: config.host };
816
+ }
817
+ if (config.host === DEFAULT_MAC_TELNET_BROADCAST) {
818
+ const route = await discoverMacTelnetRoute({
819
+ destinationMac: config.destinationMac,
820
+ port: config.port,
821
+ timeoutMs: Math.min(config.timeoutMs, 5000)
822
+ });
823
+ if (route)
824
+ return route;
825
+ }
826
+ const sourceMac = await resolveEgressMac(config.host, config.port) ?? randomLocalMac();
827
+ return { sourceMac, host: config.host };
828
+ }
829
+
830
+ class MacTelnetSession {
831
+ options;
832
+ sessionKey;
833
+ state = "init";
834
+ outCounter = 0;
835
+ lastInCounter = null;
836
+ mtweiKeypair;
837
+ lastAckCounter = 0;
838
+ maxOutAck = -1;
839
+ pending;
840
+ activitySinceTick = false;
841
+ idleMs = 0;
842
+ lastTickMs;
843
+ constructor(options) {
844
+ this.options = options;
845
+ this.sessionKey = options.sessionKey ?? randomInt(65536);
846
+ }
847
+ get key() {
848
+ return this.sessionKey;
849
+ }
850
+ start() {
851
+ if (this.state !== "init")
852
+ return;
853
+ const bytes = this.sendPacket(MacTelnetPacketType.sessionStart);
854
+ this.pending = { bytes, ackTarget: 0, attempts: 0, elapsedMs: 0 };
855
+ this.state = "session-start-sent";
856
+ }
857
+ handlePacket(bytes) {
858
+ if (this.state === "closed")
859
+ return;
860
+ let header;
861
+ try {
862
+ header = decodeHeader(bytes, { fromServer: true });
863
+ } catch {
864
+ return;
865
+ }
866
+ switch (header.type) {
867
+ case MacTelnetPacketType.ack:
868
+ if (this.matchesSession(header))
869
+ this.onAck(header);
870
+ return;
871
+ case MacTelnetPacketType.data:
872
+ if (this.matchesSession(header))
873
+ this.onDataPacket(header, bytes);
874
+ return;
875
+ case MacTelnetPacketType.end:
876
+ if (this.matchesSession(header))
877
+ this.onEnd(header);
878
+ return;
879
+ case MacTelnetPacketType.ping:
880
+ if (header.version === 1 && macEquals(header.destinationMac, this.options.sourceMac)) {
881
+ this.onPing(header);
882
+ }
883
+ }
884
+ }
885
+ matchesSession(header) {
886
+ return header.version === 1 && header.sessionKey === this.sessionKey && macEquals(header.sourceMac, this.options.destinationMac) && macEquals(header.destinationMac, this.options.sourceMac);
887
+ }
888
+ onPing(header) {
889
+ this.activitySinceTick = true;
890
+ this.emit(encodeHeader({
891
+ type: MacTelnetPacketType.pong,
892
+ sourceMac: this.options.sourceMac,
893
+ destinationMac: this.options.destinationMac,
894
+ sessionKey: header.sessionKey,
895
+ counter: header.counter
896
+ }));
897
+ }
898
+ onAck(header) {
899
+ this.activitySinceTick = true;
900
+ this.maxOutAck = Math.max(this.maxOutAck, header.counter);
901
+ if (this.pending && this.maxOutAck >= this.pending.ackTarget) {
902
+ this.pending = undefined;
903
+ }
904
+ if (this.state !== "session-start-sent")
905
+ return;
906
+ const blocks = [encodeControlBlock(MacTelnetControlType.beginAuth)];
907
+ if (this.options.offerMtwei !== false) {
908
+ this.mtweiKeypair = ecSrp5Keygen();
909
+ blocks.push(encodeControlBlock(MacTelnetControlType.passwordSalt, mtweiOfferValue(this.identityUsername(), this.mtweiKeypair.publicKey)));
910
+ }
911
+ this.sendData(blocks);
912
+ this.state = "auth-begin-sent";
913
+ }
914
+ identityUsername() {
915
+ return this.options.username.split("+", 1)[0] ?? this.options.username;
916
+ }
917
+ onDataPacket(header, bytes) {
918
+ this.activitySinceTick = true;
919
+ const payload = bytes.subarray(MAC_TELNET_HEADER_LEN);
920
+ this.acknowledge(header.counter, payload.length);
921
+ if (!this.acceptCounter(header.counter))
922
+ return;
923
+ let blocks;
924
+ try {
925
+ blocks = parseControlBlocks(payload);
926
+ } catch (error) {
927
+ this.fail(error);
928
+ return;
929
+ }
930
+ for (const block of blocks)
931
+ this.handleControlBlock(block);
932
+ }
933
+ handleControlBlock(block) {
934
+ switch (block.type) {
935
+ case MacTelnetControlType.passwordSalt:
936
+ this.handlePasswordSalt(block.value);
937
+ return;
938
+ case MacTelnetControlType.endAuth:
939
+ if (this.state === "auth-sent") {
940
+ this.state = "auth-complete";
941
+ }
942
+ return;
943
+ case "plaindata":
944
+ this.handleTerminalData(block.value);
945
+ return;
946
+ case MacTelnetControlType.packetError:
947
+ this.fail(new Error(`The device reported a MAC-Telnet error: ${new TextDecoder().decode(block.value)}. ` + "Check the credentials and that MAC-Telnet is enabled on the device."));
948
+ }
949
+ }
950
+ handleTerminalData(data) {
951
+ if (this.state === "auth-complete") {
952
+ if (/login failed/i.test(new TextDecoder().decode(data))) {
953
+ this.fail(new Error("MAC-Telnet login failed: incorrect username or password."));
954
+ return;
955
+ }
956
+ this.state = "ready";
957
+ this.options.onReady?.();
958
+ if (data.length > 0)
959
+ this.options.onData?.(data);
960
+ return;
961
+ }
962
+ if (this.state === "ready" && data.length > 0)
963
+ this.options.onData?.(data);
964
+ }
965
+ handlePasswordSalt(salt) {
966
+ if (this.state !== "auth-begin-sent")
967
+ return;
968
+ let passwordValue;
969
+ if (salt.length === 16) {
970
+ passwordValue = macTelnetPasswordHash(this.options.password, salt);
971
+ } else if (salt.length === EC_SRP5_PUBKEY_LEN + 16) {
972
+ if (!this.mtweiKeypair) {
973
+ this.fail(new Error("The device requires MTWEI but this session did not offer it. " + "Leave MTWEI enabled (the default) so the client advertises a public key."));
974
+ return;
975
+ }
976
+ const serverKey = salt.subarray(0, EC_SRP5_PUBKEY_LEN);
977
+ const mtweiSalt = salt.subarray(EC_SRP5_PUBKEY_LEN);
978
+ const validator = ecSrp5Id(this.identityUsername(), this.options.password, mtweiSalt);
979
+ passwordValue = ecSrp5ClientProof(this.mtweiKeypair.privateKey, serverKey, this.mtweiKeypair.publicKey, validator);
980
+ } else {
981
+ this.fail(new Error(`The device offered an unsupported MAC-Telnet salt length (${salt.length}). ` + "Expected 16 (MD5) or 49 (MTWEI); confirm the device and RouterOS version."));
982
+ return;
983
+ }
984
+ const username = new TextEncoder().encode(this.options.username);
985
+ const terminalType = new TextEncoder().encode(this.options.terminalType ?? "vt102");
986
+ const blocks = [
987
+ encodeControlBlock(MacTelnetControlType.password, passwordValue),
988
+ encodeControlBlock(MacTelnetControlType.username, username),
989
+ encodeControlBlock(MacTelnetControlType.terminalType, terminalType),
990
+ encodeControlBlock(MacTelnetControlType.terminalWidth, encodeTerminalDimension(this.options.terminalWidth ?? 80)),
991
+ encodeControlBlock(MacTelnetControlType.terminalHeight, encodeTerminalDimension(this.options.terminalHeight ?? 24))
992
+ ];
993
+ this.sendData(blocks);
994
+ this.state = "auth-sent";
995
+ }
996
+ onEnd(header) {
997
+ this.emit(encodeHeader({
998
+ type: MacTelnetPacketType.end,
999
+ sourceMac: this.options.sourceMac,
1000
+ destinationMac: this.options.destinationMac,
1001
+ sessionKey: header.sessionKey,
1002
+ counter: 0
1003
+ }));
1004
+ if (this.state === "auth-complete") {
1005
+ this.fail(new Error("MAC-Telnet login failed: the device closed the session after authentication. " + "Check the credentials configured for this device."));
1006
+ return;
1007
+ }
1008
+ this.close();
1009
+ }
1010
+ sendInput(bytes) {
1011
+ if (this.state !== "ready") {
1012
+ throw new Error("Cannot send input before the MAC-Telnet session is ready.");
1013
+ }
1014
+ this.sendData([bytes]);
1015
+ }
1016
+ end() {
1017
+ if (this.state === "closed")
1018
+ return;
1019
+ this.emit(encodeHeader({
1020
+ type: MacTelnetPacketType.end,
1021
+ sourceMac: this.options.sourceMac,
1022
+ destinationMac: this.options.destinationMac,
1023
+ sessionKey: this.sessionKey,
1024
+ counter: this.outCounter
1025
+ }));
1026
+ this.close();
1027
+ }
1028
+ close(error) {
1029
+ if (this.state === "closed")
1030
+ return;
1031
+ this.state = "closed";
1032
+ this.pending = undefined;
1033
+ this.options.sink.close();
1034
+ this.options.onClose?.(error);
1035
+ }
1036
+ fail(error) {
1037
+ this.close(error);
1038
+ }
1039
+ emit(bytes) {
1040
+ try {
1041
+ this.options.sink.send(bytes);
1042
+ } catch {}
1043
+ }
1044
+ acceptCounter(counter) {
1045
+ if (this.lastInCounter === null) {
1046
+ this.lastInCounter = counter;
1047
+ return true;
1048
+ }
1049
+ if (counter > this.lastInCounter) {
1050
+ this.lastInCounter = counter;
1051
+ return true;
1052
+ }
1053
+ return false;
1054
+ }
1055
+ acknowledge(counter, payloadLength) {
1056
+ this.lastAckCounter = counter + payloadLength >>> 0;
1057
+ this.activitySinceTick = true;
1058
+ this.emit(encodeHeader({
1059
+ type: MacTelnetPacketType.ack,
1060
+ sourceMac: this.options.sourceMac,
1061
+ destinationMac: this.options.destinationMac,
1062
+ sessionKey: this.sessionKey,
1063
+ counter: this.lastAckCounter
1064
+ }));
1065
+ }
1066
+ sendPacket(type, payload) {
1067
+ const bytes = buildPacket({
1068
+ type,
1069
+ sourceMac: this.options.sourceMac,
1070
+ destinationMac: this.options.destinationMac,
1071
+ sessionKey: this.sessionKey,
1072
+ counter: this.outCounter,
1073
+ payload
1074
+ });
1075
+ this.activitySinceTick = true;
1076
+ this.emit(bytes);
1077
+ return bytes;
1078
+ }
1079
+ sendData(parts) {
1080
+ const payload = concatBytes2(parts);
1081
+ const bytes = this.sendPacket(MacTelnetPacketType.data, payload);
1082
+ this.outCounter = this.outCounter + payload.length >>> 0;
1083
+ this.pending = {
1084
+ bytes,
1085
+ ackTarget: this.outCounter,
1086
+ attempts: 0,
1087
+ elapsedMs: 0
1088
+ };
1089
+ }
1090
+ tick(nowMs) {
1091
+ if (this.state === "closed")
1092
+ return;
1093
+ const delta = this.lastTickMs === undefined ? 0 : Math.max(0, nowMs - this.lastTickMs);
1094
+ this.lastTickMs = nowMs;
1095
+ if (this.activitySinceTick) {
1096
+ this.idleMs = 0;
1097
+ this.activitySinceTick = false;
1098
+ } else {
1099
+ this.idleMs += delta;
1100
+ }
1101
+ if (this.idleMs >= MAC_TELNET_KEEPALIVE_IDLE_MS) {
1102
+ this.sendKeepalive();
1103
+ this.idleMs = 0;
1104
+ }
1105
+ const pending = this.pending;
1106
+ if (pending && pending.attempts < MAC_TELNET_RETRANSMIT_SCHEDULE_MS.length) {
1107
+ pending.elapsedMs += delta;
1108
+ const wait = MAC_TELNET_RETRANSMIT_SCHEDULE_MS[pending.attempts];
1109
+ if (pending.elapsedMs >= wait) {
1110
+ this.emit(pending.bytes);
1111
+ pending.attempts += 1;
1112
+ pending.elapsedMs = 0;
1113
+ }
1114
+ }
1115
+ }
1116
+ sendKeepalive() {
1117
+ this.emit(encodeHeader({
1118
+ type: MacTelnetPacketType.ack,
1119
+ sourceMac: this.options.sourceMac,
1120
+ destinationMac: this.options.destinationMac,
1121
+ sessionKey: this.sessionKey,
1122
+ counter: this.lastAckCounter
1123
+ }));
1124
+ }
1125
+ }
1126
+
1127
+ // src/mac-telnet/console.ts
1128
+ var ESC = "\x1B";
1129
+ var enc = new TextEncoder;
1130
+ var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
1131
+ var ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
1132
+ var ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
1133
+ var TICK_INTERVAL_MS = 15;
1134
+ var LICENSE_RE = /do you want to see the software license/i;
1135
+ function emulateScreen(text) {
1136
+ const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
1137
+ const lines = [[]];
1138
+ let row = 0;
1139
+ let col = 0;
1140
+ for (const ch of clean) {
1141
+ const code = ch.charCodeAt(0);
1142
+ if (ch === `
1143
+ `) {
1144
+ row += 1;
1145
+ if (!lines[row])
1146
+ lines[row] = [];
1147
+ col = 0;
1148
+ } else if (ch === "\r") {
1149
+ col = 0;
1150
+ } else if (code === 8) {
1151
+ col = Math.max(0, col - 1);
1152
+ } else if (code >= 32 && code !== 127) {
1153
+ const line = lines[row];
1154
+ line[col] = ch;
1155
+ col += 1;
1156
+ }
1157
+ }
1158
+ return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
1159
+ }
1160
+ function extractCommandOutput(raw, command) {
1161
+ const lines = emulateScreen(raw);
1162
+ let start = 1;
1163
+ if (command !== undefined && lines.length > 0) {
1164
+ const first = lines[0] ?? "";
1165
+ const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
1166
+ const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
1167
+ let consumed = echoedOnFirst.length;
1168
+ while (consumed < command.length && start < lines.length) {
1169
+ consumed += (lines[start] ?? "").length;
1170
+ start += 1;
1171
+ }
1172
+ }
1173
+ const body = lines.slice(start);
1174
+ while (body.length > 0) {
1175
+ const last = body[body.length - 1];
1176
+ if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
1177
+ body.pop();
1178
+ continue;
1179
+ }
1180
+ break;
1181
+ }
1182
+ return body.join(`
1183
+ `);
1184
+ }
1185
+
1186
+ class MacTelnetConsole {
1187
+ options;
1188
+ session;
1189
+ buffer = "";
1190
+ ready = false;
1191
+ closed = false;
1192
+ closeError;
1193
+ waiter;
1194
+ tickTimer;
1195
+ readyWaiters = [];
1196
+ decoder = new TextDecoder;
1197
+ probeTail = "";
1198
+ constructor(options) {
1199
+ this.options = {
1200
+ rows: 9999,
1201
+ cols: 512,
1202
+ primeTimeoutMs: 30000,
1203
+ commandTimeoutMs: 15000,
1204
+ settleMs: 150,
1205
+ acceptLicense: true,
1206
+ ...options
1207
+ };
1208
+ this.session = new MacTelnetSession({
1209
+ sink: options.sink,
1210
+ sourceMac: options.sourceMac,
1211
+ destinationMac: options.destinationMac,
1212
+ username: options.username,
1213
+ password: options.password,
1214
+ sessionKey: options.sessionKey,
1215
+ terminalType: "vt102",
1216
+ terminalWidth: this.options.cols,
1217
+ terminalHeight: this.options.rows,
1218
+ onReady: () => this.onReady(),
1219
+ onData: (bytes) => this.onData(bytes),
1220
+ onClose: (error) => this.onClose(error)
1221
+ });
1222
+ }
1223
+ handlePacket(bytes) {
1224
+ try {
1225
+ this.session.handlePacket(bytes);
1226
+ } catch (error) {
1227
+ this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
1228
+ }
1229
+ }
1230
+ async open() {
1231
+ this.tickTimer = setInterval(() => {
1232
+ try {
1233
+ this.session.tick(Date.now());
1234
+ } catch {}
1235
+ }, TICK_INTERVAL_MS);
1236
+ this.tickTimer.unref?.();
1237
+ this.session.start();
1238
+ await this.waitReady(this.options.primeTimeoutMs);
1239
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
1240
+ if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
1241
+ this.buffer = "";
1242
+ this.session.sendInput(enc.encode("n\r"));
1243
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
1244
+ }
1245
+ this.buffer = "";
1246
+ this.session.sendInput(enc.encode("\r"));
1247
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
1248
+ this.buffer = "";
1249
+ }
1250
+ async run(cli) {
1251
+ this.assertOpen();
1252
+ this.buffer = "";
1253
+ this.session.sendInput(enc.encode(`${cli}\r`));
1254
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
1255
+ const raw = this.buffer;
1256
+ return { output: extractCommandOutput(raw, cli), raw };
1257
+ }
1258
+ close() {
1259
+ if (this.tickTimer) {
1260
+ clearInterval(this.tickTimer);
1261
+ this.tickTimer = undefined;
1262
+ }
1263
+ if (!this.closed)
1264
+ this.session.end();
1265
+ }
1266
+ get isReady() {
1267
+ return this.ready && !this.closed;
1268
+ }
1269
+ onReady() {
1270
+ this.ready = true;
1271
+ for (const w of this.readyWaiters.splice(0))
1272
+ w.resolve();
1273
+ }
1274
+ onData(bytes) {
1275
+ const chunk = this.decoder.decode(bytes, { stream: true });
1276
+ const combined = `${this.probeTail}${chunk}`;
1277
+ this.answerSizeProbe(combined);
1278
+ this.probeTail = combined.slice(-3);
1279
+ this.buffer += chunk;
1280
+ this.checkWaiter();
1281
+ }
1282
+ onClose(error) {
1283
+ this.closed = true;
1284
+ this.closeError = error;
1285
+ if (this.tickTimer) {
1286
+ clearInterval(this.tickTimer);
1287
+ this.tickTimer = undefined;
1288
+ }
1289
+ const failure = error ?? new Error("The MAC-Telnet console session closed.");
1290
+ for (const w of this.readyWaiters.splice(0))
1291
+ w.reject(failure);
1292
+ if (this.waiter) {
1293
+ const waiter = this.waiter;
1294
+ this.waiter = undefined;
1295
+ clearTimeout(waiter.timeout);
1296
+ if (waiter.settle)
1297
+ clearTimeout(waiter.settle);
1298
+ waiter.reject(failure);
1299
+ }
1300
+ }
1301
+ answerSizeProbe(chunk) {
1302
+ if (chunk.includes(`${ESC}[6n`)) {
1303
+ this.session.sendInput(enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`));
1304
+ }
1305
+ if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
1306
+ this.session.sendInput(enc.encode(`${ESC}[?6c`));
1307
+ }
1308
+ }
1309
+ endsWithPrompt(buffer) {
1310
+ const lines = emulateScreen(buffer).filter((line) => line.length > 0);
1311
+ return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
1312
+ }
1313
+ waitReady(timeoutMs) {
1314
+ if (this.ready)
1315
+ return Promise.resolve();
1316
+ if (this.closed) {
1317
+ return Promise.reject(this.closeError ?? new Error("The MAC-Telnet session closed before login completed."));
1318
+ }
1319
+ return new Promise((resolve, reject) => {
1320
+ let timer;
1321
+ const entry = {
1322
+ resolve: () => {
1323
+ clearTimeout(timer);
1324
+ resolve();
1325
+ },
1326
+ reject: (error) => {
1327
+ clearTimeout(timer);
1328
+ reject(error);
1329
+ }
1330
+ };
1331
+ timer = setTimeout(() => {
1332
+ this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
1333
+ reject(new Error("MAC-Telnet login did not complete \u2014 no console response from the device. " + "Confirm the device is reachable over mac-telnet (mac-server interface list) and the credentials are correct."));
1334
+ }, timeoutMs);
1335
+ this.readyWaiters.push(entry);
1336
+ });
1337
+ }
1338
+ waitFor(predicate, timeoutMs, label) {
1339
+ if (this.closed) {
1340
+ return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
1341
+ }
1342
+ return new Promise((resolve, reject) => {
1343
+ const timeout = setTimeout(() => {
1344
+ this.waiter = undefined;
1345
+ reject(new Error(`Timed out ${label}. The RouterOS console did not return a prompt in time; ` + "raise the timeout or confirm the device is responsive over mac-telnet."));
1346
+ }, timeoutMs);
1347
+ this.waiter = { predicate, resolve, reject, timeout };
1348
+ this.checkWaiter();
1349
+ });
1350
+ }
1351
+ checkWaiter() {
1352
+ const waiter = this.waiter;
1353
+ if (!waiter)
1354
+ return;
1355
+ if (!waiter.predicate(this.buffer)) {
1356
+ if (waiter.settle) {
1357
+ clearTimeout(waiter.settle);
1358
+ waiter.settle = undefined;
1359
+ }
1360
+ return;
1361
+ }
1362
+ if (waiter.settle)
1363
+ clearTimeout(waiter.settle);
1364
+ waiter.settle = setTimeout(() => {
1365
+ if (this.waiter !== waiter)
1366
+ return;
1367
+ this.waiter = undefined;
1368
+ clearTimeout(waiter.timeout);
1369
+ waiter.resolve();
1370
+ }, this.options.settleMs);
1371
+ }
1372
+ assertOpen() {
1373
+ if (!this.ready || this.closed) {
1374
+ throw new Error("The MAC-Telnet console is not open. Call open() and await it before running commands.");
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ // src/mac-telnet/client.ts
1380
+ class MikroTikMacTelnetClient {
1381
+ transport = null;
1382
+ console = null;
1383
+ opts;
1384
+ lastError;
1385
+ constructor(opts) {
1386
+ this.opts = {
1387
+ port: MAC_TELNET_PORT,
1388
+ timeoutMs: 1e4,
1389
+ ...opts
1390
+ };
1391
+ }
1392
+ async connect() {
1393
+ this.lastError = undefined;
1394
+ try {
1395
+ const destinationMac = parseMac(this.opts.mac);
1396
+ const explicitSourceMac = this.opts.sourceMac ? parseMac(this.opts.sourceMac) : undefined;
1397
+ const host = this.opts.host ?? DEFAULT_MAC_TELNET_BROADCAST;
1398
+ const route = await resolveMacTelnetRoute({
1399
+ destinationMac,
1400
+ host,
1401
+ port: this.opts.port,
1402
+ timeoutMs: this.opts.timeoutMs,
1403
+ explicitSourceMac
1404
+ });
1405
+ const transport = createUdpMacTelnetTransport({
1406
+ host: route.host,
1407
+ port: this.opts.port,
1408
+ broadcast: isBroadcastHost(route.host)
1409
+ });
1410
+ this.transport = transport;
1411
+ await transport.ready();
1412
+ const console = new MacTelnetConsole({
1413
+ sink: transport,
1414
+ sourceMac: route.sourceMac,
1415
+ destinationMac,
1416
+ username: this.opts.username,
1417
+ password: this.opts.password ?? "",
1418
+ primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
1419
+ commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
1420
+ });
1421
+ this.console = console;
1422
+ transport.onMessage((bytes) => console.handlePacket(bytes));
1423
+ await console.open();
1424
+ return true;
1425
+ } catch (e) {
1426
+ this.lastError = e instanceof Error ? e.message : String(e);
1427
+ logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
1428
+ this.disconnect();
1429
+ return false;
1430
+ }
1431
+ }
1432
+ async run(command) {
1433
+ if (!this.console || !this.console.isReady) {
1434
+ throw new Error("Not connected to MikroTik device (MAC-Telnet)");
1435
+ }
1436
+ const { output } = await this.console.run(command);
1437
+ return output;
1438
+ }
1439
+ disconnect() {
1440
+ try {
1441
+ this.console?.close();
1442
+ } catch {}
1443
+ try {
1444
+ this.transport?.close();
1445
+ } catch {}
1446
+ this.console = null;
1447
+ this.transport = null;
1448
+ }
1449
+ }
1450
+
250
1451
  // src/ssh/client.ts
1452
+ import { readFileSync as readFileSync3 } from "fs";
1453
+ import { Client } from "ssh2";
251
1454
  function decodeOutput(data) {
252
1455
  if (!data || data.length === 0)
253
1456
  return "";
@@ -281,7 +1484,7 @@ class MikroTikSSHClient {
281
1484
  cfg.privateKey = this.opts.privateKey;
282
1485
  } else if (this.opts.keyFilename) {
283
1486
  try {
284
- cfg.privateKey = readFileSync2(this.opts.keyFilename);
1487
+ cfg.privateKey = readFileSync3(this.opts.keyFilename);
285
1488
  } catch (e) {
286
1489
  this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
287
1490
  logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
@@ -347,6 +1550,42 @@ class MikroTikSSHClient {
347
1550
  }
348
1551
  }
349
1552
 
1553
+ // src/core/transport.ts
1554
+ function isMacTelnetDevice(dc) {
1555
+ return Boolean(dc.mac);
1556
+ }
1557
+ function createDeviceClient(dc) {
1558
+ if (isMacTelnetDevice(dc)) {
1559
+ return new MikroTikMacTelnetClient({
1560
+ mac: dc.mac,
1561
+ username: dc.username,
1562
+ password: dc.password,
1563
+ sourceMac: dc.sourceMac,
1564
+ host: dc.macHost,
1565
+ port: dc.macPort,
1566
+ timeoutMs: dc.timeoutMs
1567
+ });
1568
+ }
1569
+ return new MikroTikSSHClient({
1570
+ host: dc.host,
1571
+ username: dc.username,
1572
+ password: dc.password,
1573
+ keyFilename: dc.keyFilename,
1574
+ privateKey: dc.privateKey,
1575
+ keyPassphrase: dc.keyPassphrase,
1576
+ port: dc.port,
1577
+ timeoutMs: dc.timeoutMs
1578
+ });
1579
+ }
1580
+ function connectErrorMessage(name, dc, lastError) {
1581
+ const reason = lastError ? ` \u2014 ${lastError}` : "";
1582
+ if (dc.mac) {
1583
+ return `Failed to connect to MikroTik device '${name}' over MAC-Telnet at ${dc.mac}${reason}. ` + "Check you are on the same Layer-2 segment, MAC-Telnet is enabled (/tool mac-server) on a reachable interface, and the credentials are correct.";
1584
+ }
1585
+ const authMode = dc.keyFilename || dc.privateKey ? "SSH key" : dc.password ? "password" : "no credentials";
1586
+ return `Failed to connect to MikroTik device '${name}' at ${dc.host}:${dc.port} (auth: ${authMode})${reason}. ` + "Check the host/port are reachable, the SSH service is enabled (/ip service), and the credentials are correct.";
1587
+ }
1588
+
350
1589
  // src/ssh/safe-mode.ts
351
1590
  var PROMPT_RE = /\[.+?@.+?\] (?:<SAFE> )?> ?$/m;
352
1591
  var ANSI_RE = /\x1B(?:\[[0-9;]*[mA-HJ-MSTfhilnprsu]|[()][0-9A-Za-z]|\[?\?\d+[hl])/g;
@@ -381,6 +1620,9 @@ class SafeModeManager {
381
1620
  if (this.active)
382
1621
  return "Safe mode is already active.";
383
1622
  const dc = getDevice(this.deviceName);
1623
+ if (dc.mac) {
1624
+ return "Error: Safe Mode is not supported for a MAC-Telnet device " + `('${this.deviceName}' is reached by MAC ${dc.mac}). ` + "Connect over SSH (configure host/credentials) to use Safe Mode.";
1625
+ }
384
1626
  const ssh = new MikroTikSSHClient({
385
1627
  host: dc.host,
386
1628
  username: dc.username,
@@ -515,25 +1757,14 @@ function getSafeModeManager(deviceName) {
515
1757
  async function runOnce(command, deviceName) {
516
1758
  const name = resolveDeviceName(deviceName);
517
1759
  const dc = getDevice(deviceName);
518
- const ssh = new MikroTikSSHClient({
519
- host: dc.host,
520
- username: dc.username,
521
- password: dc.password,
522
- keyFilename: dc.keyFilename,
523
- privateKey: dc.privateKey,
524
- keyPassphrase: dc.keyPassphrase,
525
- port: dc.port,
526
- timeoutMs: dc.timeoutMs
527
- });
1760
+ const client = createDeviceClient(dc);
528
1761
  try {
529
- if (!await ssh.connect()) {
530
- const authMode = dc.keyFilename || dc.privateKey ? "SSH key" : dc.password ? "password" : "no credentials";
531
- const reason = ssh.lastError ? ` \u2014 ${ssh.lastError}` : "";
532
- throw new Error(`Failed to connect to MikroTik device '${name}' at ${dc.host}:${dc.port} (auth: ${authMode})${reason}. ` + "Check the host/port are reachable, the SSH service is enabled (/ip service), and the credentials are correct.");
1762
+ if (!await client.connect()) {
1763
+ throw new Error(connectErrorMessage(name, dc, client.lastError));
533
1764
  }
534
- return await ssh.run(command);
1765
+ return await client.run(command);
535
1766
  } finally {
536
- ssh.disconnect();
1767
+ client.disconnect();
537
1768
  }
538
1769
  }
539
1770
  async function executeMikrotikCommand(command, ctx) {
@@ -664,7 +1895,7 @@ function parseFlagLegend(text) {
664
1895
  if (!line)
665
1896
  return out;
666
1897
  const body = line.replace(/^\s*Flags:\s*/, "");
667
- for (const part of body.split(",")) {
1898
+ for (const part of body.split(/[;,]/)) {
668
1899
  const m = part.match(/^\s*([A-Za-z])\s*-\s*(.+?)\s*$/);
669
1900
  if (m)
670
1901
  out[m[1]] = m[2];
@@ -686,7 +1917,7 @@ function parseKvTokens(chunk) {
686
1917
  return out;
687
1918
  }
688
1919
  var INDEX_LINE = /^\s*(\d+)\s+(.*)$/;
689
- var LEADING_FLAGS = /^([A-Za-z]+)\s+(?=[\w.-]+=)/;
1920
+ var LEADING_FLAGS = /^([A-Z]+)(?=\s|$)/;
690
1921
  function unionColumns(rows) {
691
1922
  const seen = new Set;
692
1923
  const cols = [];
@@ -709,6 +1940,8 @@ function parseDetailRecords(lines) {
709
1940
  const row = { "#": current.index };
710
1941
  if (current.flags.trim())
711
1942
  row.flags = current.flags.trim();
1943
+ if (current.comment.trim())
1944
+ row.comment = current.comment.trim();
712
1945
  Object.assign(row, parseKvTokens(current.chunk));
713
1946
  rows.push(row);
714
1947
  current = null;
@@ -717,11 +1950,28 @@ function parseDetailRecords(lines) {
717
1950
  const m = line.match(INDEX_LINE);
718
1951
  if (m) {
719
1952
  flush();
720
- const rest = m[2];
1953
+ let rest = m[2];
1954
+ let flags = "";
721
1955
  const fm = rest.match(LEADING_FLAGS);
722
- current = fm ? { index: m[1], flags: fm[1], chunk: rest.slice(fm[0].length) } : { index: m[1], flags: "", chunk: rest };
1956
+ if (fm) {
1957
+ flags = fm[1];
1958
+ rest = rest.slice(fm[0].length).replace(/^\s+/, "");
1959
+ }
1960
+ let comment = "";
1961
+ const cm = rest.match(/;;;\s*(.*)$/);
1962
+ if (cm) {
1963
+ comment = cm[1];
1964
+ rest = rest.slice(0, cm.index).replace(/\s+$/, "");
1965
+ }
1966
+ current = { index: m[1], flags, comment, chunk: rest };
723
1967
  } else if (current) {
724
- current.chunk += ` ${line}`;
1968
+ const cm = line.match(/;;;\s*(.*)$/);
1969
+ if (cm) {
1970
+ current.comment = `${current.comment} ${cm[1]}`.trim();
1971
+ current.chunk += ` ${line.slice(0, cm.index)}`;
1972
+ } else {
1973
+ current.chunk += ` ${line}`;
1974
+ }
725
1975
  }
726
1976
  }
727
1977
  flush();
@@ -766,7 +2016,7 @@ function parseColumnarRecords(lines) {
766
2016
  }
767
2017
  function parseRecords(text) {
768
2018
  const lines = text.split(`
769
- `).filter((l) => !/^\s*Flags:/.test(l));
2019
+ `).filter((l) => !/^\s*(Flags|Columns):/.test(l));
770
2020
  const hasKv = /[A-Za-z][\w.-]*=/.test(text);
771
2021
  if (hasKv && lines.some((l) => INDEX_LINE.test(l))) {
772
2022
  const rows = parseDetailRecords(lines);
@@ -1068,8 +2318,8 @@ function defineTool(def) {
1068
2318
  function registerTools(server, modules, opts = {}) {
1069
2319
  let count = 0;
1070
2320
  const seen = new Set;
1071
- for (const mod of modules) {
1072
- for (const tool of mod) {
2321
+ for (const mod2 of modules) {
2322
+ for (const tool of mod2) {
1073
2323
  if (seen.has(tool.name)) {
1074
2324
  throw new Error(`Duplicate tool name registered: ${tool.name}`);
1075
2325
  }
@@ -1086,7 +2336,7 @@ function registerTools(server, modules, opts = {}) {
1086
2336
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1087
2337
 
1088
2338
  // src/core/ui-resources.ts
1089
- import { readFileSync as readFileSync3 } from "fs";
2339
+ import { readFileSync as readFileSync4 } from "fs";
1090
2340
  import { join as join3 } from "path";
1091
2341
  import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
1092
2342
 
@@ -1133,7 +2383,7 @@ function registerUiResources(server) {
1133
2383
  registerAppResource(server, view.name, uri, { description: view.description, mimeType: RESOURCE_MIME_TYPE }, () => {
1134
2384
  let html;
1135
2385
  try {
1136
- html = readFileSync3(file, "utf8");
2386
+ html = readFileSync4(file, "utf8");
1137
2387
  } catch {
1138
2388
  html = placeholderHtml(view);
1139
2389
  }
@@ -1144,7 +2394,7 @@ function registerUiResources(server) {
1144
2394
  }
1145
2395
 
1146
2396
  // src/prompts/index.ts
1147
- import { readFileSync as readFileSync4, readdirSync } from "fs";
2397
+ import { readFileSync as readFileSync5, readdirSync } from "fs";
1148
2398
  import { join as join4 } from "path";
1149
2399
  import { z as z3 } from "zod";
1150
2400
  function parseFrontmatter(raw) {
@@ -1216,7 +2466,7 @@ function registerPrompts(server) {
1216
2466
  for (const file of files) {
1217
2467
  let parsed;
1218
2468
  try {
1219
- parsed = parseFrontmatter(readFileSync4(join4(PROMPTS_DIR, file), "utf8"));
2469
+ parsed = parseFrontmatter(readFileSync5(join4(PROMPTS_DIR, file), "utf8"));
1220
2470
  } catch (e) {
1221
2471
  logger.warn(`Skipping prompt ${file}: ${String(e)}`);
1222
2472
  continue;
@@ -1395,12 +2645,10 @@ var appViewTools = [
1395
2645
  async handler(_a, ctx) {
1396
2646
  const device = resolveDeviceName(ctx.device);
1397
2647
  ctx.info(`Building system dashboard for '${device}'`);
1398
- const [identity, resource, health, routerboard] = await Promise.all([
1399
- printRecord("/system identity print", ctx),
1400
- printRecord("/system resource print", ctx),
1401
- printRecord("/system health print", ctx),
1402
- printRecord("/system routerboard print", ctx)
1403
- ]);
2648
+ const identity = await printRecord("/system identity print", ctx);
2649
+ const resource = await printRecord("/system resource print", ctx);
2650
+ const health = await printRecord("/system health print", ctx);
2651
+ const routerboard = await printRecord("/system routerboard print", ctx);
1404
2652
  const memTotal = parseSizeToBytes(resource["total-memory"]);
1405
2653
  const memFree = parseSizeToBytes(resource["free-memory"]);
1406
2654
  const memUsed = memTotal !== null && memFree !== null ? memTotal - memFree : null;
@@ -17326,7 +18574,7 @@ var allToolModules = moduleCatalog.map((m) => m.tools);
17326
18574
  // package.json
17327
18575
  var package_default = {
17328
18576
  name: "@usex/mikrotik-mcp",
17329
- version: "2.0.0",
18577
+ version: "2.2.0",
17330
18578
  description: "Bun-native MCP server for MikroTik RouterOS \u2014 200+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
17331
18579
  keywords: [
17332
18580
  "ai",