@usex/mikrotik-mcp 2.1.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/cli.js CHANGED
@@ -29,6 +29,10 @@ var DeviceConfigSchema = z.object({
29
29
  privateKey: z.string().optional(),
30
30
  keyPassphrase: z.string().optional(),
31
31
  timeoutMs: z.coerce.number().int().positive().default(1e4),
32
+ mac: z.string().optional(),
33
+ sourceMac: z.string().optional(),
34
+ macHost: z.string().optional(),
35
+ macPort: z.coerce.number().int().positive().optional(),
32
36
  description: z.string().optional()
33
37
  });
34
38
  var S3ConfigSchema = z.object({
@@ -116,7 +120,11 @@ function loadConfig(argv = process.argv.slice(2)) {
116
120
  keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
117
121
  privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
118
122
  keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
119
- timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS")
123
+ timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
124
+ mac: pick("mac", "MIKROTIK_MAC"),
125
+ sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
126
+ macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
127
+ macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
120
128
  };
121
129
  const hasSingle = Object.values(single).some((v) => v !== undefined);
122
130
  const devices = {};
@@ -250,8 +258,1205 @@ var logger = {
250
258
  error: (m) => emit("error", m)
251
259
  };
252
260
 
253
- // src/ssh/client.ts
261
+ // src/mac-telnet/protocol.ts
262
+ import { createHash as createHash2, randomBytes as randomBytes2, randomInt } from "crypto";
263
+ import { createSocket } from "dgram";
254
264
  import { readFileSync as readFileSync2 } from "fs";
265
+ import { networkInterfaces } from "os";
266
+
267
+ // src/mac-telnet/ec-srp5.ts
268
+ import { createHash, randomBytes } from "crypto";
269
+ var EC_SRP5_PUBKEY_LEN = 33;
270
+ var P = BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed");
271
+ var A = BigInt("0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144");
272
+ var B = BigInt("0x7b425ed097b425ed097b425ed097b425ed097b425ed097b4260b5e9c7710c864");
273
+ var N = BigInt("0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed");
274
+ var GX = BigInt("0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad245a");
275
+ var GY = BigInt("0x5f51e65e475f794b1fe122d388b72eb36dc2b28192839e4dd6163a5d81312c14");
276
+ var W2M = BigInt("0x555555555555555555555555555555555555555555555555555555555552db9c");
277
+ var M2W = BigInt("0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad2451");
278
+ var SQRT_M1 = modPow(2n, (P - 1n) / 4n, P);
279
+ var G = { x: GX, y: GY };
280
+ function mod(value, m) {
281
+ const r = value % m;
282
+ return r < 0n ? r + m : r;
283
+ }
284
+ function modPow(base, exp, m) {
285
+ let result = 1n;
286
+ let b = mod(base, m);
287
+ let e = exp;
288
+ while (e > 0n) {
289
+ if (e & 1n)
290
+ result = result * b % m;
291
+ b = b * b % m;
292
+ e >>= 1n;
293
+ }
294
+ return result;
295
+ }
296
+ function modInv(value) {
297
+ return modPow(value, P - 2n, P);
298
+ }
299
+ function modSqrt(a) {
300
+ const aa = mod(a, P);
301
+ if (aa === 0n)
302
+ return 0n;
303
+ let x = modPow(aa, (P + 3n) / 8n, P);
304
+ if (x * x % P === aa)
305
+ return x;
306
+ x = mod(x * SQRT_M1, P);
307
+ if (x * x % P === aa)
308
+ return x;
309
+ return null;
310
+ }
311
+ function bytesToBigIntBE(bytes) {
312
+ let value = 0n;
313
+ for (const byte of bytes)
314
+ value = value << 8n | BigInt(byte);
315
+ return value;
316
+ }
317
+ function bigIntToBytesBE(value, length) {
318
+ const out = new Uint8Array(length);
319
+ let v = value;
320
+ for (let i = length - 1;i >= 0; i -= 1) {
321
+ out[i] = Number(v & 0xffn);
322
+ v >>= 8n;
323
+ }
324
+ return out;
325
+ }
326
+ function sha256(...chunks) {
327
+ const hash = createHash("sha256");
328
+ for (const chunk of chunks)
329
+ hash.update(chunk);
330
+ return new Uint8Array(hash.digest());
331
+ }
332
+ function concatBytes(parts) {
333
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
334
+ const out = new Uint8Array(total);
335
+ let cursor = 0;
336
+ for (const part of parts) {
337
+ out.set(part, cursor);
338
+ cursor += part.length;
339
+ }
340
+ return out;
341
+ }
342
+ function pointAdd(p, q) {
343
+ if (p === null)
344
+ return q;
345
+ if (q === null)
346
+ return p;
347
+ if (p.x === q.x) {
348
+ if (mod(p.y + q.y, P) === 0n)
349
+ return null;
350
+ return pointDouble(p);
351
+ }
352
+ const slope = mod((q.y - p.y) * modInv(mod(q.x - p.x, P)), P);
353
+ const x = mod(slope * slope - p.x - q.x, P);
354
+ const y = mod(slope * (p.x - x) - p.y, P);
355
+ return { x, y };
356
+ }
357
+ function pointDouble(p) {
358
+ if (p === null)
359
+ return null;
360
+ if (p.y === 0n)
361
+ return null;
362
+ const slope = mod((3n * p.x * p.x + A) * modInv(mod(2n * p.y, P)), P);
363
+ const x = mod(slope * slope - 2n * p.x, P);
364
+ const y = mod(slope * (p.x - x) - p.y, P);
365
+ return { x, y };
366
+ }
367
+ function scalarMul(scalar, point) {
368
+ let result = null;
369
+ let addend = point;
370
+ let k = scalar;
371
+ while (k > 0n) {
372
+ if (k & 1n)
373
+ result = pointAdd(result, addend);
374
+ addend = pointDouble(addend);
375
+ k >>= 1n;
376
+ }
377
+ return result;
378
+ }
379
+ function liftX(xWeier, parity) {
380
+ const x = mod(xWeier, P);
381
+ const rhs = mod(x * x * x + A * x + B, P);
382
+ const y0 = modSqrt(rhs);
383
+ if (y0 === null)
384
+ return null;
385
+ const y = Number(y0 & 1n) === (parity & 1) ? y0 : mod(-y0, P);
386
+ return { x, y };
387
+ }
388
+ function encodePoint(point) {
389
+ if (point === null) {
390
+ throw new Error("Cannot encode the EC-SRP5 point at infinity.");
391
+ }
392
+ const out = new Uint8Array(EC_SRP5_PUBKEY_LEN);
393
+ out.set(bigIntToBytesBE(mod(point.x + W2M, P), 32), 0);
394
+ out[32] = Number(point.y & 1n);
395
+ return out;
396
+ }
397
+ function decodePoint(key) {
398
+ if (key.length !== EC_SRP5_PUBKEY_LEN) {
399
+ throw new Error(`EC-SRP5 public key must be ${EC_SRP5_PUBKEY_LEN} bytes (got ${key.length}).`);
400
+ }
401
+ const xWeier = mod(bytesToBigIntBE(key.subarray(0, 32)) + M2W, P);
402
+ const point = liftX(xWeier, key[32]);
403
+ if (point === null) {
404
+ throw new Error("EC-SRP5 public key X is not a valid curve point.");
405
+ }
406
+ return point;
407
+ }
408
+ function ecSrp5Keygen(privBytes) {
409
+ const priv = privBytes ? Uint8Array.from(privBytes) : new Uint8Array(randomBytes(32));
410
+ if (priv.length !== 32) {
411
+ throw new Error("EC-SRP5 private key seed must be 32 bytes.");
412
+ }
413
+ priv[0] = priv[0] & 248;
414
+ priv[31] = priv[31] & 127;
415
+ priv[31] = priv[31] | 64;
416
+ const privateKey = bytesToBigIntBE(priv);
417
+ return { privateKey, publicKey: encodePoint(scalarMul(privateKey, G)) };
418
+ }
419
+ function ecSrp5Id(username, password, salt) {
420
+ const inner = sha256(new TextEncoder().encode(`${username}:${password}`));
421
+ return sha256(salt, inner);
422
+ }
423
+ function redp1(montgomeryX, parity) {
424
+ let seed = bytesToBigIntBE(sha256(montgomeryX));
425
+ for (;; ) {
426
+ const candidate = sha256(bigIntToBytesBE(seed, 32));
427
+ const xWeier = mod(bytesToBigIntBE(candidate) + M2W, P);
428
+ const point = liftX(xWeier, parity);
429
+ if (point !== null)
430
+ return point;
431
+ seed = mod(seed + 1n, 1n << 256n);
432
+ }
433
+ }
434
+ function ecSrp5ClientShared(privateKey, serverKey, clientKey, validator) {
435
+ const serverPoint = decodePoint(serverKey);
436
+ const v = bytesToBigIntBE(validator);
437
+ const vG = scalarMul(v, G);
438
+ if (vG === null) {
439
+ throw new Error("EC-SRP5 validator produced the point at infinity.");
440
+ }
441
+ const gamma = redp1(bigIntToBytesBE(mod(vG.x + W2M, P), 32), 1);
442
+ const wB = pointAdd(serverPoint, gamma);
443
+ const j = sha256(clientKey.subarray(0, 32), serverKey.subarray(0, 32));
444
+ const scalar = mod(v * bytesToBigIntBE(j) + privateKey, N);
445
+ const pt = scalarMul(scalar, wB);
446
+ if (pt === null) {
447
+ throw new Error("EC-SRP5 shared point computed as infinity.");
448
+ }
449
+ const z2 = bigIntToBytesBE(mod(pt.x + W2M, P), 32);
450
+ return { j, z: z2 };
451
+ }
452
+ function ecSrp5ClientProof(privateKey, serverKey, clientKey, validator) {
453
+ const { j, z: z2 } = ecSrp5ClientShared(privateKey, serverKey, clientKey, validator);
454
+ return sha256(j, z2);
455
+ }
456
+
457
+ // src/mac-telnet/mtwei.ts
458
+ function mtweiOfferValue(username, publicKey) {
459
+ return concatBytes([new TextEncoder().encode(username), Uint8Array.of(0), publicKey]);
460
+ }
461
+
462
+ // src/mac-telnet/protocol.ts
463
+ var MAC_TELNET_PORT = 20561;
464
+ var MAC_TELNET_HEADER_LEN = 22;
465
+ var MAC_TELNET_CONTROL_HEADER_LEN = 9;
466
+ var MAC_TELNET_CONTROL_MAGIC = Uint8Array.of(86, 52, 18, 255);
467
+ var MAC_TELNET_CLIENT_TYPE = Uint8Array.of(0, 21);
468
+ var MAC_TELNET_RETRANSMIT_SCHEDULE_MS = [
469
+ 15,
470
+ 20,
471
+ 30,
472
+ 50,
473
+ 90,
474
+ 170,
475
+ 330,
476
+ 660,
477
+ 1000
478
+ ];
479
+ var MAC_TELNET_KEEPALIVE_IDLE_MS = 8000;
480
+ var MacTelnetPacketType = {
481
+ sessionStart: 0,
482
+ data: 1,
483
+ ack: 2,
484
+ ping: 4,
485
+ pong: 5,
486
+ end: 255
487
+ };
488
+ var MacTelnetControlType = {
489
+ beginAuth: 0,
490
+ passwordSalt: 1,
491
+ password: 2,
492
+ username: 3,
493
+ terminalType: 4,
494
+ terminalWidth: 5,
495
+ terminalHeight: 6,
496
+ packetError: 7,
497
+ endAuth: 9
498
+ };
499
+ function parseMac(value) {
500
+ const parts = value.trim().split(/[:\-.]/);
501
+ if (parts.length !== 6) {
502
+ throw new Error(`"${value}" is not a 6-octet MAC address. Provide a MAC like aa:bb:cc:dd:ee:ff.`);
503
+ }
504
+ const octets = new Uint8Array(6);
505
+ for (let index = 0;index < 6; index += 1) {
506
+ const octet = Number.parseInt(parts[index], 16);
507
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
508
+ throw new Error(`"${value}" has an invalid octet "${parts[index]}". Each octet must be a two-digit hex value (00\u2013ff).`);
509
+ }
510
+ octets[index] = octet;
511
+ }
512
+ return octets;
513
+ }
514
+ function macEquals(a, b) {
515
+ if (a.length !== b.length)
516
+ return false;
517
+ for (let index = 0;index < a.length; index += 1) {
518
+ if (a[index] !== b[index])
519
+ return false;
520
+ }
521
+ return true;
522
+ }
523
+ function encodeHeader(options) {
524
+ const data = new Uint8Array(MAC_TELNET_HEADER_LEN);
525
+ const view = new DataView(data.buffer);
526
+ data[0] = 1;
527
+ data[1] = options.type;
528
+ data.set(options.sourceMac, 2);
529
+ data.set(options.destinationMac, 8);
530
+ const sessionKeyOffset = options.fromServer ? 16 : 14;
531
+ const clientTypeOffset = options.fromServer ? 14 : 16;
532
+ view.setUint16(sessionKeyOffset, options.sessionKey & 65535, false);
533
+ data.set(MAC_TELNET_CLIENT_TYPE, clientTypeOffset);
534
+ view.setUint32(18, options.counter >>> 0, false);
535
+ return data;
536
+ }
537
+ function decodeHeader(bytes, options = {}) {
538
+ if (bytes.length < MAC_TELNET_HEADER_LEN) {
539
+ throw new Error(`MAC-Telnet packet is too short (${bytes.length} bytes, need at least ${MAC_TELNET_HEADER_LEN}).`);
540
+ }
541
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
542
+ const fromServer = options.fromServer ?? true;
543
+ const sessionKeyOffset = fromServer ? 16 : 14;
544
+ return {
545
+ version: bytes[0],
546
+ type: bytes[1],
547
+ sourceMac: bytes.slice(2, 8),
548
+ destinationMac: bytes.slice(8, 14),
549
+ sessionKey: view.getUint16(sessionKeyOffset, false),
550
+ counter: view.getUint32(18, false)
551
+ };
552
+ }
553
+ function encodeControlBlock(type, value = new Uint8Array(0)) {
554
+ const block = new Uint8Array(MAC_TELNET_CONTROL_HEADER_LEN + value.length);
555
+ block.set(MAC_TELNET_CONTROL_MAGIC, 0);
556
+ block[4] = type;
557
+ new DataView(block.buffer).setUint32(5, value.length >>> 0, false);
558
+ block.set(value, MAC_TELNET_CONTROL_HEADER_LEN);
559
+ return block;
560
+ }
561
+ function parseControlBlocks(payload) {
562
+ const blocks = [];
563
+ let offset = 0;
564
+ while (offset < payload.length) {
565
+ const remaining = payload.length - offset;
566
+ 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];
567
+ if (!hasMagic) {
568
+ blocks.push({ type: "plaindata", value: payload.slice(offset) });
569
+ break;
570
+ }
571
+ const type = payload[offset + 4];
572
+ const length = new DataView(payload.buffer, payload.byteOffset + offset + 5, 4).getUint32(0, false);
573
+ const valueStart = offset + MAC_TELNET_CONTROL_HEADER_LEN;
574
+ const valueEnd = valueStart + length;
575
+ if (valueEnd > payload.length) {
576
+ throw new Error(`MAC-Telnet control block claims ${length} bytes but only ${payload.length - valueStart} remain.`);
577
+ }
578
+ blocks.push({ type, value: payload.slice(valueStart, valueEnd) });
579
+ offset = valueEnd;
580
+ }
581
+ return blocks;
582
+ }
583
+ function macTelnetPasswordHash(password, salt) {
584
+ const digest = createHash2("md5").update(Buffer.from([0])).update(Buffer.from(password, "utf8")).update(salt).digest();
585
+ const out = new Uint8Array(17);
586
+ out[0] = 0;
587
+ out.set(digest, 1);
588
+ return out;
589
+ }
590
+ function encodeTerminalDimension(value) {
591
+ const out = new Uint8Array(2);
592
+ new DataView(out.buffer).setUint16(0, value & 65535, true);
593
+ return out;
594
+ }
595
+ function concatBytes2(parts) {
596
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
597
+ const out = new Uint8Array(total);
598
+ let cursor = 0;
599
+ for (const part of parts) {
600
+ out.set(part, cursor);
601
+ cursor += part.length;
602
+ }
603
+ return out;
604
+ }
605
+ function buildPacket(options) {
606
+ const header = encodeHeader({
607
+ type: options.type,
608
+ sourceMac: options.sourceMac,
609
+ destinationMac: options.destinationMac,
610
+ sessionKey: options.sessionKey,
611
+ counter: options.counter
612
+ });
613
+ if (!options.payload || options.payload.length === 0)
614
+ return header;
615
+ return concatBytes2([header, options.payload]);
616
+ }
617
+ function createUdpMacTelnetTransport(options) {
618
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
619
+ let handler;
620
+ socket.on("message", (message) => {
621
+ handler?.(new Uint8Array(message));
622
+ });
623
+ const readyPromise = new Promise((resolve, reject) => {
624
+ socket.once("error", reject);
625
+ socket.bind(0, () => {
626
+ if (options.broadcast) {
627
+ try {
628
+ socket.setBroadcast(true);
629
+ } catch {}
630
+ }
631
+ resolve();
632
+ });
633
+ });
634
+ return {
635
+ send(bytes) {
636
+ socket.send(bytes, options.port, options.host);
637
+ },
638
+ close() {
639
+ try {
640
+ socket.close();
641
+ } catch {}
642
+ },
643
+ onMessage(next) {
644
+ handler = next;
645
+ },
646
+ ready() {
647
+ return readyPromise;
648
+ }
649
+ };
650
+ }
651
+ function isZeroMac(mac) {
652
+ return mac.every((octet) => octet === 0);
653
+ }
654
+ function tryParseMac(value) {
655
+ if (!value)
656
+ return;
657
+ try {
658
+ return parseMac(value);
659
+ } catch {
660
+ return;
661
+ }
662
+ }
663
+ function hardwareMacOf(name) {
664
+ try {
665
+ const sys = readFileSync2(`/sys/class/net/${name}/address`, "utf8").trim();
666
+ const mac = tryParseMac(sys);
667
+ if (mac && !isZeroMac(mac))
668
+ return mac;
669
+ } catch {}
670
+ return;
671
+ }
672
+ function ipv4ToInt(addr) {
673
+ const parts = addr.split(".");
674
+ if (parts.length !== 4)
675
+ return;
676
+ let value = 0;
677
+ for (const part of parts) {
678
+ const octet = Number(part);
679
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255)
680
+ return;
681
+ value = value << 8 | octet;
682
+ }
683
+ return value >>> 0;
684
+ }
685
+ function intToIpv4(value) {
686
+ return [24, 16, 8, 0].map((shift) => value >>> shift & 255).join(".");
687
+ }
688
+ function directedBroadcast(address, netmask) {
689
+ const addr = ipv4ToInt(address);
690
+ const mask = ipv4ToInt(netmask);
691
+ if (addr === undefined || mask === undefined)
692
+ return;
693
+ return intToIpv4((addr & mask | ~mask >>> 0) >>> 0);
694
+ }
695
+ function listBroadcastInterfaces() {
696
+ const out = [];
697
+ for (const [name, infos] of Object.entries(networkInterfaces())) {
698
+ for (const info of infos ?? []) {
699
+ if (info.family !== "IPv4" || info.internal)
700
+ continue;
701
+ let mac = tryParseMac(info.mac);
702
+ if (!mac || isZeroMac(mac))
703
+ mac = hardwareMacOf(name);
704
+ if (!mac || isZeroMac(mac))
705
+ continue;
706
+ const broadcast = directedBroadcast(info.address, info.netmask);
707
+ if (!broadcast)
708
+ continue;
709
+ out.push({ name, address: info.address, broadcast, mac });
710
+ }
711
+ }
712
+ return out;
713
+ }
714
+ async function egressAddressFor(host, port) {
715
+ return await new Promise((resolve) => {
716
+ const probe = createSocket("udp4");
717
+ const done = (addr) => {
718
+ try {
719
+ probe.close();
720
+ } catch {}
721
+ resolve(addr);
722
+ };
723
+ probe.on("error", () => done(undefined));
724
+ try {
725
+ probe.connect(port, host, () => {
726
+ try {
727
+ done(probe.address().address);
728
+ } catch {
729
+ done(undefined);
730
+ }
731
+ });
732
+ } catch {
733
+ done(undefined);
734
+ }
735
+ });
736
+ }
737
+ async function resolveEgressMac(host, port) {
738
+ const localAddr = await egressAddressFor(host, port);
739
+ if (!localAddr)
740
+ return;
741
+ for (const ifc of listBroadcastInterfaces()) {
742
+ if (ifc.address === localAddr)
743
+ return ifc.mac;
744
+ }
745
+ return;
746
+ }
747
+ async function discoverMacTelnetRoute(opts) {
748
+ const interfaces = listBroadcastInterfaces();
749
+ if (interfaces.length === 0)
750
+ return;
751
+ const sessionKey = opts.sessionKey ?? randomInt(65536);
752
+ return await new Promise((resolve) => {
753
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
754
+ let settled = false;
755
+ const timers = [];
756
+ const finish = (route) => {
757
+ if (settled)
758
+ return;
759
+ settled = true;
760
+ for (const t of timers)
761
+ clearTimeout(t);
762
+ try {
763
+ socket.close();
764
+ } catch {}
765
+ resolve(route);
766
+ };
767
+ socket.on("error", () => finish(undefined));
768
+ socket.on("message", (message) => {
769
+ let header;
770
+ try {
771
+ header = decodeHeader(new Uint8Array(message), { fromServer: true });
772
+ } catch {
773
+ return;
774
+ }
775
+ if (header.version !== 1 || header.sessionKey !== sessionKey)
776
+ return;
777
+ if (header.type !== MacTelnetPacketType.ack && header.type !== MacTelnetPacketType.data) {
778
+ return;
779
+ }
780
+ if (!macEquals(header.sourceMac, opts.destinationMac))
781
+ return;
782
+ const winner = interfaces.find((ifc) => macEquals(ifc.mac, header.destinationMac));
783
+ if (winner)
784
+ finish({ sourceMac: winner.mac, host: winner.broadcast });
785
+ });
786
+ const spray = () => {
787
+ for (const ifc of interfaces) {
788
+ const packet = buildPacket({
789
+ type: MacTelnetPacketType.sessionStart,
790
+ sourceMac: ifc.mac,
791
+ destinationMac: opts.destinationMac,
792
+ sessionKey,
793
+ counter: 0
794
+ });
795
+ try {
796
+ socket.send(packet, opts.port, ifc.broadcast);
797
+ } catch {}
798
+ }
799
+ };
800
+ socket.bind(0, () => {
801
+ try {
802
+ socket.setBroadcast(true);
803
+ } catch {}
804
+ spray();
805
+ timers.push(setTimeout(spray, 250));
806
+ timers.push(setTimeout(spray, 700));
807
+ timers.push(setTimeout(() => finish(undefined), opts.timeoutMs));
808
+ });
809
+ });
810
+ }
811
+ var DEFAULT_MAC_TELNET_BROADCAST = "255.255.255.255";
812
+ function randomLocalMac() {
813
+ const octets = new Uint8Array(randomBytes2(6));
814
+ octets[0] = octets[0] & 254 | 2;
815
+ return octets;
816
+ }
817
+ function isBroadcastHost(host) {
818
+ return host === DEFAULT_MAC_TELNET_BROADCAST || host.endsWith(".255");
819
+ }
820
+ async function resolveMacTelnetRoute(config) {
821
+ if (config.explicitSourceMac) {
822
+ return { sourceMac: config.explicitSourceMac, host: config.host };
823
+ }
824
+ if (config.host === DEFAULT_MAC_TELNET_BROADCAST) {
825
+ const route = await discoverMacTelnetRoute({
826
+ destinationMac: config.destinationMac,
827
+ port: config.port,
828
+ timeoutMs: Math.min(config.timeoutMs, 5000)
829
+ });
830
+ if (route)
831
+ return route;
832
+ }
833
+ const sourceMac = await resolveEgressMac(config.host, config.port) ?? randomLocalMac();
834
+ return { sourceMac, host: config.host };
835
+ }
836
+
837
+ class MacTelnetSession {
838
+ options;
839
+ sessionKey;
840
+ state = "init";
841
+ outCounter = 0;
842
+ lastInCounter = null;
843
+ mtweiKeypair;
844
+ lastAckCounter = 0;
845
+ maxOutAck = -1;
846
+ pending;
847
+ activitySinceTick = false;
848
+ idleMs = 0;
849
+ lastTickMs;
850
+ constructor(options) {
851
+ this.options = options;
852
+ this.sessionKey = options.sessionKey ?? randomInt(65536);
853
+ }
854
+ get key() {
855
+ return this.sessionKey;
856
+ }
857
+ start() {
858
+ if (this.state !== "init")
859
+ return;
860
+ const bytes = this.sendPacket(MacTelnetPacketType.sessionStart);
861
+ this.pending = { bytes, ackTarget: 0, attempts: 0, elapsedMs: 0 };
862
+ this.state = "session-start-sent";
863
+ }
864
+ handlePacket(bytes) {
865
+ if (this.state === "closed")
866
+ return;
867
+ let header;
868
+ try {
869
+ header = decodeHeader(bytes, { fromServer: true });
870
+ } catch {
871
+ return;
872
+ }
873
+ switch (header.type) {
874
+ case MacTelnetPacketType.ack:
875
+ if (this.matchesSession(header))
876
+ this.onAck(header);
877
+ return;
878
+ case MacTelnetPacketType.data:
879
+ if (this.matchesSession(header))
880
+ this.onDataPacket(header, bytes);
881
+ return;
882
+ case MacTelnetPacketType.end:
883
+ if (this.matchesSession(header))
884
+ this.onEnd(header);
885
+ return;
886
+ case MacTelnetPacketType.ping:
887
+ if (header.version === 1 && macEquals(header.destinationMac, this.options.sourceMac)) {
888
+ this.onPing(header);
889
+ }
890
+ }
891
+ }
892
+ matchesSession(header) {
893
+ return header.version === 1 && header.sessionKey === this.sessionKey && macEquals(header.sourceMac, this.options.destinationMac) && macEquals(header.destinationMac, this.options.sourceMac);
894
+ }
895
+ onPing(header) {
896
+ this.activitySinceTick = true;
897
+ this.emit(encodeHeader({
898
+ type: MacTelnetPacketType.pong,
899
+ sourceMac: this.options.sourceMac,
900
+ destinationMac: this.options.destinationMac,
901
+ sessionKey: header.sessionKey,
902
+ counter: header.counter
903
+ }));
904
+ }
905
+ onAck(header) {
906
+ this.activitySinceTick = true;
907
+ this.maxOutAck = Math.max(this.maxOutAck, header.counter);
908
+ if (this.pending && this.maxOutAck >= this.pending.ackTarget) {
909
+ this.pending = undefined;
910
+ }
911
+ if (this.state !== "session-start-sent")
912
+ return;
913
+ const blocks = [encodeControlBlock(MacTelnetControlType.beginAuth)];
914
+ if (this.options.offerMtwei !== false) {
915
+ this.mtweiKeypair = ecSrp5Keygen();
916
+ blocks.push(encodeControlBlock(MacTelnetControlType.passwordSalt, mtweiOfferValue(this.identityUsername(), this.mtweiKeypair.publicKey)));
917
+ }
918
+ this.sendData(blocks);
919
+ this.state = "auth-begin-sent";
920
+ }
921
+ identityUsername() {
922
+ return this.options.username.split("+", 1)[0] ?? this.options.username;
923
+ }
924
+ onDataPacket(header, bytes) {
925
+ this.activitySinceTick = true;
926
+ const payload = bytes.subarray(MAC_TELNET_HEADER_LEN);
927
+ this.acknowledge(header.counter, payload.length);
928
+ if (!this.acceptCounter(header.counter))
929
+ return;
930
+ let blocks;
931
+ try {
932
+ blocks = parseControlBlocks(payload);
933
+ } catch (error) {
934
+ this.fail(error);
935
+ return;
936
+ }
937
+ for (const block of blocks)
938
+ this.handleControlBlock(block);
939
+ }
940
+ handleControlBlock(block) {
941
+ switch (block.type) {
942
+ case MacTelnetControlType.passwordSalt:
943
+ this.handlePasswordSalt(block.value);
944
+ return;
945
+ case MacTelnetControlType.endAuth:
946
+ if (this.state === "auth-sent") {
947
+ this.state = "auth-complete";
948
+ }
949
+ return;
950
+ case "plaindata":
951
+ this.handleTerminalData(block.value);
952
+ return;
953
+ case MacTelnetControlType.packetError:
954
+ 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."));
955
+ }
956
+ }
957
+ handleTerminalData(data) {
958
+ if (this.state === "auth-complete") {
959
+ if (/login failed/i.test(new TextDecoder().decode(data))) {
960
+ this.fail(new Error("MAC-Telnet login failed: incorrect username or password."));
961
+ return;
962
+ }
963
+ this.state = "ready";
964
+ this.options.onReady?.();
965
+ if (data.length > 0)
966
+ this.options.onData?.(data);
967
+ return;
968
+ }
969
+ if (this.state === "ready" && data.length > 0)
970
+ this.options.onData?.(data);
971
+ }
972
+ handlePasswordSalt(salt) {
973
+ if (this.state !== "auth-begin-sent")
974
+ return;
975
+ let passwordValue;
976
+ if (salt.length === 16) {
977
+ passwordValue = macTelnetPasswordHash(this.options.password, salt);
978
+ } else if (salt.length === EC_SRP5_PUBKEY_LEN + 16) {
979
+ if (!this.mtweiKeypair) {
980
+ 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."));
981
+ return;
982
+ }
983
+ const serverKey = salt.subarray(0, EC_SRP5_PUBKEY_LEN);
984
+ const mtweiSalt = salt.subarray(EC_SRP5_PUBKEY_LEN);
985
+ const validator = ecSrp5Id(this.identityUsername(), this.options.password, mtweiSalt);
986
+ passwordValue = ecSrp5ClientProof(this.mtweiKeypair.privateKey, serverKey, this.mtweiKeypair.publicKey, validator);
987
+ } else {
988
+ 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."));
989
+ return;
990
+ }
991
+ const username = new TextEncoder().encode(this.options.username);
992
+ const terminalType = new TextEncoder().encode(this.options.terminalType ?? "vt102");
993
+ const blocks = [
994
+ encodeControlBlock(MacTelnetControlType.password, passwordValue),
995
+ encodeControlBlock(MacTelnetControlType.username, username),
996
+ encodeControlBlock(MacTelnetControlType.terminalType, terminalType),
997
+ encodeControlBlock(MacTelnetControlType.terminalWidth, encodeTerminalDimension(this.options.terminalWidth ?? 80)),
998
+ encodeControlBlock(MacTelnetControlType.terminalHeight, encodeTerminalDimension(this.options.terminalHeight ?? 24))
999
+ ];
1000
+ this.sendData(blocks);
1001
+ this.state = "auth-sent";
1002
+ }
1003
+ onEnd(header) {
1004
+ this.emit(encodeHeader({
1005
+ type: MacTelnetPacketType.end,
1006
+ sourceMac: this.options.sourceMac,
1007
+ destinationMac: this.options.destinationMac,
1008
+ sessionKey: header.sessionKey,
1009
+ counter: 0
1010
+ }));
1011
+ if (this.state === "auth-complete") {
1012
+ this.fail(new Error("MAC-Telnet login failed: the device closed the session after authentication. " + "Check the credentials configured for this device."));
1013
+ return;
1014
+ }
1015
+ this.close();
1016
+ }
1017
+ sendInput(bytes) {
1018
+ if (this.state !== "ready") {
1019
+ throw new Error("Cannot send input before the MAC-Telnet session is ready.");
1020
+ }
1021
+ this.sendData([bytes]);
1022
+ }
1023
+ end() {
1024
+ if (this.state === "closed")
1025
+ return;
1026
+ this.emit(encodeHeader({
1027
+ type: MacTelnetPacketType.end,
1028
+ sourceMac: this.options.sourceMac,
1029
+ destinationMac: this.options.destinationMac,
1030
+ sessionKey: this.sessionKey,
1031
+ counter: this.outCounter
1032
+ }));
1033
+ this.close();
1034
+ }
1035
+ close(error) {
1036
+ if (this.state === "closed")
1037
+ return;
1038
+ this.state = "closed";
1039
+ this.pending = undefined;
1040
+ this.options.sink.close();
1041
+ this.options.onClose?.(error);
1042
+ }
1043
+ fail(error) {
1044
+ this.close(error);
1045
+ }
1046
+ emit(bytes) {
1047
+ try {
1048
+ this.options.sink.send(bytes);
1049
+ } catch {}
1050
+ }
1051
+ acceptCounter(counter) {
1052
+ if (this.lastInCounter === null) {
1053
+ this.lastInCounter = counter;
1054
+ return true;
1055
+ }
1056
+ if (counter > this.lastInCounter) {
1057
+ this.lastInCounter = counter;
1058
+ return true;
1059
+ }
1060
+ return false;
1061
+ }
1062
+ acknowledge(counter, payloadLength) {
1063
+ this.lastAckCounter = counter + payloadLength >>> 0;
1064
+ this.activitySinceTick = true;
1065
+ this.emit(encodeHeader({
1066
+ type: MacTelnetPacketType.ack,
1067
+ sourceMac: this.options.sourceMac,
1068
+ destinationMac: this.options.destinationMac,
1069
+ sessionKey: this.sessionKey,
1070
+ counter: this.lastAckCounter
1071
+ }));
1072
+ }
1073
+ sendPacket(type, payload) {
1074
+ const bytes = buildPacket({
1075
+ type,
1076
+ sourceMac: this.options.sourceMac,
1077
+ destinationMac: this.options.destinationMac,
1078
+ sessionKey: this.sessionKey,
1079
+ counter: this.outCounter,
1080
+ payload
1081
+ });
1082
+ this.activitySinceTick = true;
1083
+ this.emit(bytes);
1084
+ return bytes;
1085
+ }
1086
+ sendData(parts) {
1087
+ const payload = concatBytes2(parts);
1088
+ const bytes = this.sendPacket(MacTelnetPacketType.data, payload);
1089
+ this.outCounter = this.outCounter + payload.length >>> 0;
1090
+ this.pending = {
1091
+ bytes,
1092
+ ackTarget: this.outCounter,
1093
+ attempts: 0,
1094
+ elapsedMs: 0
1095
+ };
1096
+ }
1097
+ tick(nowMs) {
1098
+ if (this.state === "closed")
1099
+ return;
1100
+ const delta = this.lastTickMs === undefined ? 0 : Math.max(0, nowMs - this.lastTickMs);
1101
+ this.lastTickMs = nowMs;
1102
+ if (this.activitySinceTick) {
1103
+ this.idleMs = 0;
1104
+ this.activitySinceTick = false;
1105
+ } else {
1106
+ this.idleMs += delta;
1107
+ }
1108
+ if (this.idleMs >= MAC_TELNET_KEEPALIVE_IDLE_MS) {
1109
+ this.sendKeepalive();
1110
+ this.idleMs = 0;
1111
+ }
1112
+ const pending = this.pending;
1113
+ if (pending && pending.attempts < MAC_TELNET_RETRANSMIT_SCHEDULE_MS.length) {
1114
+ pending.elapsedMs += delta;
1115
+ const wait = MAC_TELNET_RETRANSMIT_SCHEDULE_MS[pending.attempts];
1116
+ if (pending.elapsedMs >= wait) {
1117
+ this.emit(pending.bytes);
1118
+ pending.attempts += 1;
1119
+ pending.elapsedMs = 0;
1120
+ }
1121
+ }
1122
+ }
1123
+ sendKeepalive() {
1124
+ this.emit(encodeHeader({
1125
+ type: MacTelnetPacketType.ack,
1126
+ sourceMac: this.options.sourceMac,
1127
+ destinationMac: this.options.destinationMac,
1128
+ sessionKey: this.sessionKey,
1129
+ counter: this.lastAckCounter
1130
+ }));
1131
+ }
1132
+ }
1133
+
1134
+ // src/mac-telnet/console.ts
1135
+ var ESC = "\x1B";
1136
+ var enc = new TextEncoder;
1137
+ var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
1138
+ var ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
1139
+ var ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
1140
+ var TICK_INTERVAL_MS = 15;
1141
+ var LICENSE_RE = /do you want to see the software license/i;
1142
+ function emulateScreen(text) {
1143
+ const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
1144
+ const lines = [[]];
1145
+ let row = 0;
1146
+ let col = 0;
1147
+ for (const ch of clean) {
1148
+ const code = ch.charCodeAt(0);
1149
+ if (ch === `
1150
+ `) {
1151
+ row += 1;
1152
+ if (!lines[row])
1153
+ lines[row] = [];
1154
+ col = 0;
1155
+ } else if (ch === "\r") {
1156
+ col = 0;
1157
+ } else if (code === 8) {
1158
+ col = Math.max(0, col - 1);
1159
+ } else if (code >= 32 && code !== 127) {
1160
+ const line = lines[row];
1161
+ line[col] = ch;
1162
+ col += 1;
1163
+ }
1164
+ }
1165
+ return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
1166
+ }
1167
+ function extractCommandOutput(raw, command) {
1168
+ const lines = emulateScreen(raw);
1169
+ let start = 1;
1170
+ if (command !== undefined && lines.length > 0) {
1171
+ const first = lines[0] ?? "";
1172
+ const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
1173
+ const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
1174
+ let consumed = echoedOnFirst.length;
1175
+ while (consumed < command.length && start < lines.length) {
1176
+ consumed += (lines[start] ?? "").length;
1177
+ start += 1;
1178
+ }
1179
+ }
1180
+ const body = lines.slice(start);
1181
+ while (body.length > 0) {
1182
+ const last = body[body.length - 1];
1183
+ if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
1184
+ body.pop();
1185
+ continue;
1186
+ }
1187
+ break;
1188
+ }
1189
+ return body.join(`
1190
+ `);
1191
+ }
1192
+
1193
+ class MacTelnetConsole {
1194
+ options;
1195
+ session;
1196
+ buffer = "";
1197
+ ready = false;
1198
+ closed = false;
1199
+ closeError;
1200
+ waiter;
1201
+ tickTimer;
1202
+ readyWaiters = [];
1203
+ decoder = new TextDecoder;
1204
+ probeTail = "";
1205
+ constructor(options) {
1206
+ this.options = {
1207
+ rows: 9999,
1208
+ cols: 512,
1209
+ primeTimeoutMs: 30000,
1210
+ commandTimeoutMs: 15000,
1211
+ settleMs: 150,
1212
+ acceptLicense: true,
1213
+ ...options
1214
+ };
1215
+ this.session = new MacTelnetSession({
1216
+ sink: options.sink,
1217
+ sourceMac: options.sourceMac,
1218
+ destinationMac: options.destinationMac,
1219
+ username: options.username,
1220
+ password: options.password,
1221
+ sessionKey: options.sessionKey,
1222
+ terminalType: "vt102",
1223
+ terminalWidth: this.options.cols,
1224
+ terminalHeight: this.options.rows,
1225
+ onReady: () => this.onReady(),
1226
+ onData: (bytes) => this.onData(bytes),
1227
+ onClose: (error) => this.onClose(error)
1228
+ });
1229
+ }
1230
+ handlePacket(bytes) {
1231
+ try {
1232
+ this.session.handlePacket(bytes);
1233
+ } catch (error) {
1234
+ this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
1235
+ }
1236
+ }
1237
+ async open() {
1238
+ this.tickTimer = setInterval(() => {
1239
+ try {
1240
+ this.session.tick(Date.now());
1241
+ } catch {}
1242
+ }, TICK_INTERVAL_MS);
1243
+ this.tickTimer.unref?.();
1244
+ this.session.start();
1245
+ await this.waitReady(this.options.primeTimeoutMs);
1246
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
1247
+ if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
1248
+ this.buffer = "";
1249
+ this.session.sendInput(enc.encode("n\r"));
1250
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
1251
+ }
1252
+ this.buffer = "";
1253
+ this.session.sendInput(enc.encode("\r"));
1254
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
1255
+ this.buffer = "";
1256
+ }
1257
+ async run(cli) {
1258
+ this.assertOpen();
1259
+ this.buffer = "";
1260
+ this.session.sendInput(enc.encode(`${cli}\r`));
1261
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
1262
+ const raw = this.buffer;
1263
+ return { output: extractCommandOutput(raw, cli), raw };
1264
+ }
1265
+ close() {
1266
+ if (this.tickTimer) {
1267
+ clearInterval(this.tickTimer);
1268
+ this.tickTimer = undefined;
1269
+ }
1270
+ if (!this.closed)
1271
+ this.session.end();
1272
+ }
1273
+ get isReady() {
1274
+ return this.ready && !this.closed;
1275
+ }
1276
+ onReady() {
1277
+ this.ready = true;
1278
+ for (const w of this.readyWaiters.splice(0))
1279
+ w.resolve();
1280
+ }
1281
+ onData(bytes) {
1282
+ const chunk = this.decoder.decode(bytes, { stream: true });
1283
+ const combined = `${this.probeTail}${chunk}`;
1284
+ this.answerSizeProbe(combined);
1285
+ this.probeTail = combined.slice(-3);
1286
+ this.buffer += chunk;
1287
+ this.checkWaiter();
1288
+ }
1289
+ onClose(error) {
1290
+ this.closed = true;
1291
+ this.closeError = error;
1292
+ if (this.tickTimer) {
1293
+ clearInterval(this.tickTimer);
1294
+ this.tickTimer = undefined;
1295
+ }
1296
+ const failure = error ?? new Error("The MAC-Telnet console session closed.");
1297
+ for (const w of this.readyWaiters.splice(0))
1298
+ w.reject(failure);
1299
+ if (this.waiter) {
1300
+ const waiter = this.waiter;
1301
+ this.waiter = undefined;
1302
+ clearTimeout(waiter.timeout);
1303
+ if (waiter.settle)
1304
+ clearTimeout(waiter.settle);
1305
+ waiter.reject(failure);
1306
+ }
1307
+ }
1308
+ answerSizeProbe(chunk) {
1309
+ if (chunk.includes(`${ESC}[6n`)) {
1310
+ this.session.sendInput(enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`));
1311
+ }
1312
+ if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
1313
+ this.session.sendInput(enc.encode(`${ESC}[?6c`));
1314
+ }
1315
+ }
1316
+ endsWithPrompt(buffer) {
1317
+ const lines = emulateScreen(buffer).filter((line) => line.length > 0);
1318
+ return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
1319
+ }
1320
+ waitReady(timeoutMs) {
1321
+ if (this.ready)
1322
+ return Promise.resolve();
1323
+ if (this.closed) {
1324
+ return Promise.reject(this.closeError ?? new Error("The MAC-Telnet session closed before login completed."));
1325
+ }
1326
+ return new Promise((resolve, reject) => {
1327
+ let timer;
1328
+ const entry = {
1329
+ resolve: () => {
1330
+ clearTimeout(timer);
1331
+ resolve();
1332
+ },
1333
+ reject: (error) => {
1334
+ clearTimeout(timer);
1335
+ reject(error);
1336
+ }
1337
+ };
1338
+ timer = setTimeout(() => {
1339
+ this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
1340
+ 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."));
1341
+ }, timeoutMs);
1342
+ this.readyWaiters.push(entry);
1343
+ });
1344
+ }
1345
+ waitFor(predicate, timeoutMs, label) {
1346
+ if (this.closed) {
1347
+ return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
1348
+ }
1349
+ return new Promise((resolve, reject) => {
1350
+ const timeout = setTimeout(() => {
1351
+ this.waiter = undefined;
1352
+ 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."));
1353
+ }, timeoutMs);
1354
+ this.waiter = { predicate, resolve, reject, timeout };
1355
+ this.checkWaiter();
1356
+ });
1357
+ }
1358
+ checkWaiter() {
1359
+ const waiter = this.waiter;
1360
+ if (!waiter)
1361
+ return;
1362
+ if (!waiter.predicate(this.buffer)) {
1363
+ if (waiter.settle) {
1364
+ clearTimeout(waiter.settle);
1365
+ waiter.settle = undefined;
1366
+ }
1367
+ return;
1368
+ }
1369
+ if (waiter.settle)
1370
+ clearTimeout(waiter.settle);
1371
+ waiter.settle = setTimeout(() => {
1372
+ if (this.waiter !== waiter)
1373
+ return;
1374
+ this.waiter = undefined;
1375
+ clearTimeout(waiter.timeout);
1376
+ waiter.resolve();
1377
+ }, this.options.settleMs);
1378
+ }
1379
+ assertOpen() {
1380
+ if (!this.ready || this.closed) {
1381
+ throw new Error("The MAC-Telnet console is not open. Call open() and await it before running commands.");
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ // src/mac-telnet/client.ts
1387
+ class MikroTikMacTelnetClient {
1388
+ transport = null;
1389
+ console = null;
1390
+ opts;
1391
+ lastError;
1392
+ constructor(opts) {
1393
+ this.opts = {
1394
+ port: MAC_TELNET_PORT,
1395
+ timeoutMs: 1e4,
1396
+ ...opts
1397
+ };
1398
+ }
1399
+ async connect() {
1400
+ this.lastError = undefined;
1401
+ try {
1402
+ const destinationMac = parseMac(this.opts.mac);
1403
+ const explicitSourceMac = this.opts.sourceMac ? parseMac(this.opts.sourceMac) : undefined;
1404
+ const host = this.opts.host ?? DEFAULT_MAC_TELNET_BROADCAST;
1405
+ const route = await resolveMacTelnetRoute({
1406
+ destinationMac,
1407
+ host,
1408
+ port: this.opts.port,
1409
+ timeoutMs: this.opts.timeoutMs,
1410
+ explicitSourceMac
1411
+ });
1412
+ const transport = createUdpMacTelnetTransport({
1413
+ host: route.host,
1414
+ port: this.opts.port,
1415
+ broadcast: isBroadcastHost(route.host)
1416
+ });
1417
+ this.transport = transport;
1418
+ await transport.ready();
1419
+ const console = new MacTelnetConsole({
1420
+ sink: transport,
1421
+ sourceMac: route.sourceMac,
1422
+ destinationMac,
1423
+ username: this.opts.username,
1424
+ password: this.opts.password ?? "",
1425
+ primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
1426
+ commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
1427
+ });
1428
+ this.console = console;
1429
+ transport.onMessage((bytes) => console.handlePacket(bytes));
1430
+ await console.open();
1431
+ return true;
1432
+ } catch (e) {
1433
+ this.lastError = e instanceof Error ? e.message : String(e);
1434
+ logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
1435
+ this.disconnect();
1436
+ return false;
1437
+ }
1438
+ }
1439
+ async run(command) {
1440
+ if (!this.console || !this.console.isReady) {
1441
+ throw new Error("Not connected to MikroTik device (MAC-Telnet)");
1442
+ }
1443
+ const { output } = await this.console.run(command);
1444
+ return output;
1445
+ }
1446
+ disconnect() {
1447
+ try {
1448
+ this.console?.close();
1449
+ } catch {}
1450
+ try {
1451
+ this.transport?.close();
1452
+ } catch {}
1453
+ this.console = null;
1454
+ this.transport = null;
1455
+ }
1456
+ }
1457
+
1458
+ // src/ssh/client.ts
1459
+ import { readFileSync as readFileSync3 } from "fs";
255
1460
  import { Client } from "ssh2";
256
1461
  function decodeOutput(data) {
257
1462
  if (!data || data.length === 0)
@@ -286,7 +1491,7 @@ class MikroTikSSHClient {
286
1491
  cfg.privateKey = this.opts.privateKey;
287
1492
  } else if (this.opts.keyFilename) {
288
1493
  try {
289
- cfg.privateKey = readFileSync2(this.opts.keyFilename);
1494
+ cfg.privateKey = readFileSync3(this.opts.keyFilename);
290
1495
  } catch (e) {
291
1496
  this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
292
1497
  logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
@@ -352,6 +1557,45 @@ class MikroTikSSHClient {
352
1557
  }
353
1558
  }
354
1559
 
1560
+ // src/core/transport.ts
1561
+ function isMacTelnetDevice(dc) {
1562
+ return Boolean(dc.mac);
1563
+ }
1564
+ function createDeviceClient(dc) {
1565
+ if (isMacTelnetDevice(dc)) {
1566
+ return new MikroTikMacTelnetClient({
1567
+ mac: dc.mac,
1568
+ username: dc.username,
1569
+ password: dc.password,
1570
+ sourceMac: dc.sourceMac,
1571
+ host: dc.macHost,
1572
+ port: dc.macPort,
1573
+ timeoutMs: dc.timeoutMs
1574
+ });
1575
+ }
1576
+ return new MikroTikSSHClient({
1577
+ host: dc.host,
1578
+ username: dc.username,
1579
+ password: dc.password,
1580
+ keyFilename: dc.keyFilename,
1581
+ privateKey: dc.privateKey,
1582
+ keyPassphrase: dc.keyPassphrase,
1583
+ port: dc.port,
1584
+ timeoutMs: dc.timeoutMs
1585
+ });
1586
+ }
1587
+ function describeTransport(dc) {
1588
+ return dc.mac ? `MAC ${dc.mac} (mac-telnet)` : `${dc.host}:${dc.port}`;
1589
+ }
1590
+ function connectErrorMessage(name, dc, lastError) {
1591
+ const reason = lastError ? ` \u2014 ${lastError}` : "";
1592
+ if (dc.mac) {
1593
+ 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.";
1594
+ }
1595
+ const authMode = dc.keyFilename || dc.privateKey ? "SSH key" : dc.password ? "password" : "no credentials";
1596
+ 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.";
1597
+ }
1598
+
355
1599
  // src/tools/address-list.ts
356
1600
  import { z as z3 } from "zod";
357
1601
 
@@ -389,6 +1633,9 @@ class SafeModeManager {
389
1633
  if (this.active)
390
1634
  return "Safe mode is already active.";
391
1635
  const dc = getDevice(this.deviceName);
1636
+ if (dc.mac) {
1637
+ 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.";
1638
+ }
392
1639
  const ssh = new MikroTikSSHClient({
393
1640
  host: dc.host,
394
1641
  username: dc.username,
@@ -523,25 +1770,14 @@ function getSafeModeManager(deviceName) {
523
1770
  async function runOnce(command, deviceName) {
524
1771
  const name = resolveDeviceName(deviceName);
525
1772
  const dc = getDevice(deviceName);
526
- const ssh = new MikroTikSSHClient({
527
- host: dc.host,
528
- username: dc.username,
529
- password: dc.password,
530
- keyFilename: dc.keyFilename,
531
- privateKey: dc.privateKey,
532
- keyPassphrase: dc.keyPassphrase,
533
- port: dc.port,
534
- timeoutMs: dc.timeoutMs
535
- });
1773
+ const client = createDeviceClient(dc);
536
1774
  try {
537
- if (!await ssh.connect()) {
538
- const authMode = dc.keyFilename || dc.privateKey ? "SSH key" : dc.password ? "password" : "no credentials";
539
- const reason = ssh.lastError ? ` \u2014 ${ssh.lastError}` : "";
540
- 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.");
1775
+ if (!await client.connect()) {
1776
+ throw new Error(connectErrorMessage(name, dc, client.lastError));
541
1777
  }
542
- return await ssh.run(command);
1778
+ return await client.run(command);
543
1779
  } finally {
544
- ssh.disconnect();
1780
+ client.disconnect();
545
1781
  }
546
1782
  }
547
1783
  async function executeMikrotikCommand(command, ctx) {
@@ -1114,8 +2350,8 @@ function defineTool(def) {
1114
2350
  function registerTools(server, modules, opts = {}) {
1115
2351
  let count = 0;
1116
2352
  const seen = new Set;
1117
- for (const mod of modules) {
1118
- for (const tool of mod) {
2353
+ for (const mod2 of modules) {
2354
+ for (const tool of mod2) {
1119
2355
  if (seen.has(tool.name)) {
1120
2356
  throw new Error(`Duplicate tool name registered: ${tool.name}`);
1121
2357
  }
@@ -1250,7 +2486,7 @@ ${result}`;
1250
2486
  ];
1251
2487
 
1252
2488
  // src/core/ui-resources.ts
1253
- import { readFileSync as readFileSync3 } from "fs";
2489
+ import { readFileSync as readFileSync4 } from "fs";
1254
2490
  import { join as join3 } from "path";
1255
2491
  import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
1256
2492
 
@@ -1297,7 +2533,7 @@ function registerUiResources(server) {
1297
2533
  registerAppResource(server, view.name, uri, { description: view.description, mimeType: RESOURCE_MIME_TYPE }, () => {
1298
2534
  let html;
1299
2535
  try {
1300
- html = readFileSync3(file, "utf8");
2536
+ html = readFileSync4(file, "utf8");
1301
2537
  } catch {
1302
2538
  html = placeholderHtml(view);
1303
2539
  }
@@ -17263,7 +18499,7 @@ var moduleCatalog = [
17263
18499
  var allToolModules = moduleCatalog.map((m) => m.tools);
17264
18500
 
17265
18501
  // src/observability/dashboard.ts
17266
- import { readFileSync as readFileSync4 } from "fs";
18502
+ import { readFileSync as readFileSync5 } from "fs";
17267
18503
  import { join as join4 } from "path";
17268
18504
  var {serve } = globalThis.Bun;
17269
18505
 
@@ -17276,30 +18512,24 @@ function getDeviceStatus(name) {
17276
18512
  return statuses.get(name) ?? UNKNOWN;
17277
18513
  }
17278
18514
  async function probeDevice(name, dc) {
17279
- const ssh = new MikroTikSSHClient({
17280
- host: dc.host,
17281
- username: dc.username,
17282
- password: dc.password,
17283
- port: dc.port,
17284
- keyFilename: dc.keyFilename,
17285
- privateKey: dc.privateKey,
17286
- keyPassphrase: dc.keyPassphrase,
18515
+ const client = createDeviceClient({
18516
+ ...dc,
17287
18517
  timeoutMs: Math.min(dc.timeoutMs ?? 1e4, 8000)
17288
18518
  });
17289
18519
  const t0 = Date.now();
17290
18520
  let status;
17291
18521
  try {
17292
- const ok = await ssh.connect();
18522
+ const ok = await client.connect();
17293
18523
  if (!ok) {
17294
18524
  status = {
17295
18525
  reachable: false,
17296
18526
  checkedAt: Date.now(),
17297
18527
  latencyMs: null,
17298
- error: ssh.lastError ?? "connection failed"
18528
+ error: client.lastError ?? "connection failed"
17299
18529
  };
17300
18530
  } else {
17301
- const identity = parseKeyValues(await ssh.run("/system identity print")).name;
17302
- const version = parseKeyValues(await ssh.run("/system resource print")).version;
18531
+ const identity = parseKeyValues(await client.run("/system identity print")).name;
18532
+ const version = parseKeyValues(await client.run("/system resource print")).version;
17303
18533
  status = {
17304
18534
  reachable: true,
17305
18535
  checkedAt: Date.now(),
@@ -17316,7 +18546,7 @@ async function probeDevice(name, dc) {
17316
18546
  error: e instanceof Error ? e.message : String(e)
17317
18547
  };
17318
18548
  } finally {
17319
- ssh.disconnect();
18549
+ client.disconnect();
17320
18550
  }
17321
18551
  statuses.set(name, status);
17322
18552
  return status;
@@ -17589,7 +18819,7 @@ function json(body, status = 200) {
17589
18819
  }
17590
18820
  function dashboardHtml() {
17591
18821
  try {
17592
- return readFileSync4(join4(UI_DIST_DIR, "observability.html"), "utf8");
18822
+ return readFileSync5(join4(UI_DIST_DIR, "observability.html"), "utf8");
17593
18823
  } catch {
17594
18824
  return `<!doctype html><meta charset=utf-8><body style="font:14px system-ui;padding:24px;background:#0b0d10;color:#e8eaed">
17595
18825
  <h2>MikroTik MCP \u2014 Observability Dashboard</h2>
@@ -17687,10 +18917,10 @@ function sseResponse(transportLabel) {
17687
18917
  let ping;
17688
18918
  const stream = new ReadableStream({
17689
18919
  start(controller) {
17690
- const enc = new TextEncoder;
18920
+ const enc2 = new TextEncoder;
17691
18921
  const send = (text) => {
17692
18922
  try {
17693
- controller.enqueue(enc.encode(text));
18923
+ controller.enqueue(enc2.encode(text));
17694
18924
  } catch {}
17695
18925
  };
17696
18926
  send(`event: hello
@@ -17874,7 +19104,7 @@ function corsHeaders(origin, configured) {
17874
19104
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17875
19105
 
17876
19106
  // src/prompts/index.ts
17877
- import { readFileSync as readFileSync5, readdirSync } from "fs";
19107
+ import { readFileSync as readFileSync6, readdirSync } from "fs";
17878
19108
  import { join as join5 } from "path";
17879
19109
  import { z as z87 } from "zod";
17880
19110
  function parseFrontmatter(raw) {
@@ -17946,7 +19176,7 @@ function registerPrompts(server) {
17946
19176
  for (const file of files) {
17947
19177
  let parsed;
17948
19178
  try {
17949
- parsed = parseFrontmatter(readFileSync5(join5(PROMPTS_DIR, file), "utf8"));
19179
+ parsed = parseFrontmatter(readFileSync6(join5(PROMPTS_DIR, file), "utf8"));
17950
19180
  } catch (e) {
17951
19181
  logger.warn(`Skipping prompt ${file}: ${String(e)}`);
17952
19182
  continue;
@@ -17978,7 +19208,7 @@ function registerPrompts(server) {
17978
19208
  // package.json
17979
19209
  var package_default = {
17980
19210
  name: "@usex/mikrotik-mcp",
17981
- version: "2.1.0",
19211
+ version: "2.2.0",
17982
19212
  description: "Bun-native MCP server for MikroTik RouterOS \u2014 200+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
17983
19213
  keywords: [
17984
19214
  "ai",
@@ -18295,8 +19525,8 @@ function warnIfPlaintextPasswordInContainer(anyPassword) {
18295
19525
  function listTools() {
18296
19526
  const risk = (a) => a.readOnlyHint ? "READ" : a.destructiveHint ? "DESTRUCTIVE" : "WRITE";
18297
19527
  let total = 0;
18298
- for (const mod of allToolModules) {
18299
- for (const t of mod) {
19528
+ for (const mod2 of allToolModules) {
19529
+ for (const t of mod2) {
18300
19530
  total++;
18301
19531
  process.stdout.write(`${risk(t.annotations).padEnd(12)} ${t.name.padEnd(34)} ${t.title}
18302
19532
  `);
@@ -18310,9 +19540,9 @@ function listDevicesCli() {
18310
19540
  const cfg = loadConfig();
18311
19541
  for (const [name, d] of Object.entries(cfg.devices)) {
18312
19542
  const tag = name === cfg.defaultDevice ? " (default)" : "";
18313
- const auth = d.keyFilename || d.privateKey ? "key" : d.password ? "password" : "none";
19543
+ const auth = d.mac ? "mac-telnet" : d.keyFilename || d.privateKey ? "key" : d.password ? "password" : "none";
18314
19544
  const desc = d.description ? ` \u2014 ${d.description}` : "";
18315
- process.stdout.write(`${name}${tag} ${d.username}@${d.host}:${d.port} [auth: ${auth}]${desc}
19545
+ process.stdout.write(`${name}${tag} ${d.username}@${describeTransport(d)} [auth: ${auth}]${desc}
18316
19546
  `);
18317
19547
  }
18318
19548
  process.stdout.write(`
@@ -18320,31 +19550,22 @@ ${Object.keys(cfg.devices).length} device(s)
18320
19550
  `);
18321
19551
  }
18322
19552
  async function probeDevice2(name, d) {
18323
- const authMode = d.keyFilename || d.privateKey ? "SSH key" : "password";
18324
- logger.info(`[${name}] Connecting to ${d.username}@${d.host}:${d.port} (auth: ${authMode}) \u2026`);
18325
- const ssh = new MikroTikSSHClient({
18326
- host: d.host,
18327
- username: d.username,
18328
- password: d.password,
18329
- keyFilename: d.keyFilename,
18330
- privateKey: d.privateKey,
18331
- keyPassphrase: d.keyPassphrase,
18332
- port: d.port,
18333
- timeoutMs: d.timeoutMs
18334
- });
18335
- if (!await ssh.connect()) {
18336
- logger.error(`[${name}] Connection FAILED. Check host/credentials/reachability.`);
19553
+ const authMode = d.mac ? "mac-telnet" : d.keyFilename || d.privateKey ? "SSH key" : "password";
19554
+ logger.info(`[${name}] Connecting to ${d.username}@${describeTransport(d)} (auth: ${authMode}) \u2026`);
19555
+ const client = createDeviceClient(d);
19556
+ if (!await client.connect()) {
19557
+ logger.error(`[${name}] Connection FAILED. ${client.lastError ?? "Check credentials/reachability."}`);
18337
19558
  return false;
18338
19559
  }
18339
19560
  try {
18340
- const identity = (await ssh.run("/system identity print")).trim();
19561
+ const identity = (await client.run("/system identity print")).trim();
18341
19562
  process.stdout.write(`
18342
19563
  [${name}] Connection OK.
18343
19564
  ${identity}
18344
19565
  `);
18345
19566
  return true;
18346
19567
  } finally {
18347
- ssh.disconnect();
19568
+ client.disconnect();
18348
19569
  }
18349
19570
  }
18350
19571
  async function authCheck() {