@usex/mikrotik-mcp 2.2.0 → 2.4.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 +160 -894
- package/dist/index.js +20 -882
- package/dist/ui/observability.html +7 -7
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -258,880 +258,8 @@ var logger = {
|
|
|
258
258
|
error: (m) => emit("error", m)
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
-
// src/mac-telnet/protocol.ts
|
|
262
|
-
import { createHash as createHash2, randomBytes as randomBytes2, randomInt } from "crypto";
|
|
263
|
-
import { createSocket } from "dgram";
|
|
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
261
|
// src/mac-telnet/console.ts
|
|
262
|
+
import { MacTelnetSession } from "@tikoci/centrs/protocols";
|
|
1135
263
|
var ESC = "\x1B";
|
|
1136
264
|
var enc = new TextEncoder;
|
|
1137
265
|
var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
@@ -1384,6 +512,15 @@ class MacTelnetConsole {
|
|
|
1384
512
|
}
|
|
1385
513
|
|
|
1386
514
|
// src/mac-telnet/client.ts
|
|
515
|
+
import {
|
|
516
|
+
DEFAULT_MAC_TELNET_BROADCAST,
|
|
517
|
+
isBroadcastHost,
|
|
518
|
+
MAC_TELNET_PORT,
|
|
519
|
+
createUdpMacTelnetTransport,
|
|
520
|
+
parseMac,
|
|
521
|
+
resolveMacTelnetRoute
|
|
522
|
+
} from "@tikoci/centrs/protocols";
|
|
523
|
+
|
|
1387
524
|
class MikroTikMacTelnetClient {
|
|
1388
525
|
transport = null;
|
|
1389
526
|
console = null;
|
|
@@ -1456,7 +593,7 @@ class MikroTikMacTelnetClient {
|
|
|
1456
593
|
}
|
|
1457
594
|
|
|
1458
595
|
// src/ssh/client.ts
|
|
1459
|
-
import { readFileSync as
|
|
596
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1460
597
|
import { Client } from "ssh2";
|
|
1461
598
|
function decodeOutput(data) {
|
|
1462
599
|
if (!data || data.length === 0)
|
|
@@ -1491,7 +628,7 @@ class MikroTikSSHClient {
|
|
|
1491
628
|
cfg.privateKey = this.opts.privateKey;
|
|
1492
629
|
} else if (this.opts.keyFilename) {
|
|
1493
630
|
try {
|
|
1494
|
-
cfg.privateKey =
|
|
631
|
+
cfg.privateKey = readFileSync2(this.opts.keyFilename);
|
|
1495
632
|
} catch (e) {
|
|
1496
633
|
this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
|
|
1497
634
|
logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
|
|
@@ -2350,8 +1487,8 @@ function defineTool(def) {
|
|
|
2350
1487
|
function registerTools(server, modules, opts = {}) {
|
|
2351
1488
|
let count = 0;
|
|
2352
1489
|
const seen = new Set;
|
|
2353
|
-
for (const
|
|
2354
|
-
for (const tool of
|
|
1490
|
+
for (const mod of modules) {
|
|
1491
|
+
for (const tool of mod) {
|
|
2355
1492
|
if (seen.has(tool.name)) {
|
|
2356
1493
|
throw new Error(`Duplicate tool name registered: ${tool.name}`);
|
|
2357
1494
|
}
|
|
@@ -2486,7 +1623,7 @@ ${result}`;
|
|
|
2486
1623
|
];
|
|
2487
1624
|
|
|
2488
1625
|
// src/core/ui-resources.ts
|
|
2489
|
-
import { readFileSync as
|
|
1626
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
2490
1627
|
import { join as join3 } from "path";
|
|
2491
1628
|
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
2492
1629
|
|
|
@@ -2533,7 +1670,7 @@ function registerUiResources(server) {
|
|
|
2533
1670
|
registerAppResource(server, view.name, uri, { description: view.description, mimeType: RESOURCE_MIME_TYPE }, () => {
|
|
2534
1671
|
let html;
|
|
2535
1672
|
try {
|
|
2536
|
-
html =
|
|
1673
|
+
html = readFileSync3(file, "utf8");
|
|
2537
1674
|
} catch {
|
|
2538
1675
|
html = placeholderHtml(view);
|
|
2539
1676
|
}
|
|
@@ -18499,19 +17636,74 @@ var moduleCatalog = [
|
|
|
18499
17636
|
var allToolModules = moduleCatalog.map((m) => m.tools);
|
|
18500
17637
|
|
|
18501
17638
|
// src/observability/dashboard.ts
|
|
18502
|
-
import { readFileSync as
|
|
17639
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
18503
17640
|
import { join as join4 } from "path";
|
|
18504
17641
|
var {serve } = globalThis.Bun;
|
|
18505
17642
|
|
|
18506
17643
|
// src/observability/health.ts
|
|
17644
|
+
var HISTORY_CAP = 60;
|
|
18507
17645
|
var statuses = new Map;
|
|
17646
|
+
var histories = new Map;
|
|
18508
17647
|
var timer = null;
|
|
18509
17648
|
var inFlight = false;
|
|
18510
17649
|
var UNKNOWN = { reachable: null, checkedAt: null, latencyMs: null };
|
|
18511
17650
|
function getDeviceStatus(name) {
|
|
18512
17651
|
return statuses.get(name) ?? UNKNOWN;
|
|
18513
17652
|
}
|
|
17653
|
+
function getDeviceHistory(name) {
|
|
17654
|
+
return histories.get(name) ?? [];
|
|
17655
|
+
}
|
|
17656
|
+
function pushHistory(name, sample) {
|
|
17657
|
+
const arr = histories.get(name) ?? [];
|
|
17658
|
+
arr.push(sample);
|
|
17659
|
+
if (arr.length > HISTORY_CAP)
|
|
17660
|
+
arr.splice(0, arr.length - HISTORY_CAP);
|
|
17661
|
+
histories.set(name, arr);
|
|
17662
|
+
}
|
|
17663
|
+
function parseSize(s) {
|
|
17664
|
+
if (!s)
|
|
17665
|
+
return;
|
|
17666
|
+
const m = s.trim().match(/^([\d.]+)\s*([KMGT]i?B|B)?$/i);
|
|
17667
|
+
if (!m)
|
|
17668
|
+
return;
|
|
17669
|
+
const val = Number.parseFloat(m[1]);
|
|
17670
|
+
if (!Number.isFinite(val))
|
|
17671
|
+
return;
|
|
17672
|
+
const mult = {
|
|
17673
|
+
B: 1,
|
|
17674
|
+
KIB: 1024,
|
|
17675
|
+
MIB: 1024 ** 2,
|
|
17676
|
+
GIB: 1024 ** 3,
|
|
17677
|
+
TIB: 1024 ** 4,
|
|
17678
|
+
KB: 1000,
|
|
17679
|
+
MB: 1e6,
|
|
17680
|
+
GB: 1e9,
|
|
17681
|
+
TB: 1000000000000
|
|
17682
|
+
};
|
|
17683
|
+
return val * (mult[(m[2] ?? "B").toUpperCase()] ?? 1);
|
|
17684
|
+
}
|
|
17685
|
+
function parsePercent(s) {
|
|
17686
|
+
if (!s)
|
|
17687
|
+
return;
|
|
17688
|
+
const m = s.trim().match(/^([\d.]+)\s*%?$/);
|
|
17689
|
+
return m ? Number.parseFloat(m[1]) : undefined;
|
|
17690
|
+
}
|
|
17691
|
+
function usedPct(total, free) {
|
|
17692
|
+
if (total == null || free == null || total <= 0)
|
|
17693
|
+
return;
|
|
17694
|
+
return Math.max(0, Math.min(100, (total - free) / total * 100));
|
|
17695
|
+
}
|
|
18514
17696
|
async function probeDevice(name, dc) {
|
|
17697
|
+
if (dc.mac) {
|
|
17698
|
+
const status2 = {
|
|
17699
|
+
reachable: null,
|
|
17700
|
+
checkedAt: Date.now(),
|
|
17701
|
+
latencyMs: null,
|
|
17702
|
+
error: "MAC-Telnet device \u2014 reachability is verified on tool use, not background-probed."
|
|
17703
|
+
};
|
|
17704
|
+
statuses.set(name, status2);
|
|
17705
|
+
return status2;
|
|
17706
|
+
}
|
|
18515
17707
|
const client = createDeviceClient({
|
|
18516
17708
|
...dc,
|
|
18517
17709
|
timeoutMs: Math.min(dc.timeoutMs ?? 1e4, 8000)
|
|
@@ -18529,14 +17721,42 @@ async function probeDevice(name, dc) {
|
|
|
18529
17721
|
};
|
|
18530
17722
|
} else {
|
|
18531
17723
|
const identity = parseKeyValues(await client.run("/system identity print")).name;
|
|
18532
|
-
const
|
|
17724
|
+
const r = parseKeyValues(await client.run("/system resource print"));
|
|
17725
|
+
const checkedAt = Date.now();
|
|
17726
|
+
const latencyMs = checkedAt - t0;
|
|
17727
|
+
const totalMemory = parseSize(r["total-memory"]);
|
|
17728
|
+
const freeMemory = parseSize(r["free-memory"]);
|
|
17729
|
+
const totalHdd = parseSize(r["total-hdd-space"]);
|
|
17730
|
+
const freeHdd = parseSize(r["free-hdd-space"]);
|
|
17731
|
+
const cpuLoad = parsePercent(r["cpu-load"]);
|
|
17732
|
+
const memUsedPct = usedPct(totalMemory, freeMemory);
|
|
17733
|
+
const hddUsedPct = usedPct(totalHdd, freeHdd);
|
|
17734
|
+
const cpuCount = Number.parseInt(r["cpu-count"] ?? "", 10);
|
|
18533
17735
|
status = {
|
|
18534
17736
|
reachable: true,
|
|
18535
|
-
checkedAt
|
|
18536
|
-
latencyMs
|
|
17737
|
+
checkedAt,
|
|
17738
|
+
latencyMs,
|
|
18537
17739
|
identity,
|
|
18538
|
-
version
|
|
17740
|
+
version: r.version,
|
|
17741
|
+
boardName: r["board-name"] || undefined,
|
|
17742
|
+
architecture: r["architecture-name"] || undefined,
|
|
17743
|
+
cpuCount: Number.isFinite(cpuCount) ? cpuCount : undefined,
|
|
17744
|
+
cpuLoad,
|
|
17745
|
+
freeMemory,
|
|
17746
|
+
totalMemory,
|
|
17747
|
+
memUsedPct,
|
|
17748
|
+
freeHdd,
|
|
17749
|
+
totalHdd,
|
|
17750
|
+
hddUsedPct,
|
|
17751
|
+
uptime: r.uptime || undefined
|
|
18539
17752
|
};
|
|
17753
|
+
pushHistory(name, {
|
|
17754
|
+
ts: checkedAt,
|
|
17755
|
+
cpuLoad: cpuLoad ?? null,
|
|
17756
|
+
memUsedPct: memUsedPct ?? null,
|
|
17757
|
+
hddUsedPct: hddUsedPct ?? null,
|
|
17758
|
+
latencyMs
|
|
17759
|
+
});
|
|
18540
17760
|
}
|
|
18541
17761
|
} catch (e) {
|
|
18542
17762
|
status = {
|
|
@@ -18695,6 +17915,28 @@ class SqliteEventStore {
|
|
|
18695
17915
|
const r = this.db.query("SELECT COUNT(*) AS n FROM events").get();
|
|
18696
17916
|
return r.n;
|
|
18697
17917
|
}
|
|
17918
|
+
delete(ids) {
|
|
17919
|
+
if (ids.length === 0)
|
|
17920
|
+
return 0;
|
|
17921
|
+
let removed = 0;
|
|
17922
|
+
const CHUNK = 500;
|
|
17923
|
+
for (let i = 0;i < ids.length; i += CHUNK) {
|
|
17924
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
17925
|
+
const placeholders = chunk.map((_, j) => `$id${j}`).join(",");
|
|
17926
|
+
const params = {};
|
|
17927
|
+
chunk.forEach((id, j) => {
|
|
17928
|
+
params[`$id${j}`] = id;
|
|
17929
|
+
});
|
|
17930
|
+
const res = this.db.query(`DELETE FROM events WHERE id IN (${placeholders})`).run(params);
|
|
17931
|
+
removed += Number(res.changes ?? 0);
|
|
17932
|
+
}
|
|
17933
|
+
return removed;
|
|
17934
|
+
}
|
|
17935
|
+
clear() {
|
|
17936
|
+
const n = this.total();
|
|
17937
|
+
this.db.run("DELETE FROM events");
|
|
17938
|
+
return n;
|
|
17939
|
+
}
|
|
18698
17940
|
prune(maxEvents2) {
|
|
18699
17941
|
const n = this.total();
|
|
18700
17942
|
if (n <= maxEvents2)
|
|
@@ -18819,7 +18061,7 @@ function json(body, status = 200) {
|
|
|
18819
18061
|
}
|
|
18820
18062
|
function dashboardHtml() {
|
|
18821
18063
|
try {
|
|
18822
|
-
return
|
|
18064
|
+
return readFileSync4(join4(UI_DIST_DIR, "observability.html"), "utf8");
|
|
18823
18065
|
} catch {
|
|
18824
18066
|
return `<!doctype html><meta charset=utf-8><body style="font:14px system-ui;padding:24px;background:#0b0d10;color:#e8eaed">
|
|
18825
18067
|
<h2>MikroTik MCP \u2014 Observability Dashboard</h2>
|
|
@@ -18866,7 +18108,12 @@ function deviceActivity(store2) {
|
|
|
18866
18108
|
for (const e of recent) {
|
|
18867
18109
|
if (!e.device)
|
|
18868
18110
|
continue;
|
|
18869
|
-
const a = map.get(e.device) ?? {
|
|
18111
|
+
const a = map.get(e.device) ?? {
|
|
18112
|
+
calls: 0,
|
|
18113
|
+
errors: 0,
|
|
18114
|
+
lastSeen: 0,
|
|
18115
|
+
sumMs: 0
|
|
18116
|
+
};
|
|
18870
18117
|
a.calls++;
|
|
18871
18118
|
if (e.isError)
|
|
18872
18119
|
a.errors++;
|
|
@@ -18892,12 +18139,21 @@ function devicesPayload(store2) {
|
|
|
18892
18139
|
name,
|
|
18893
18140
|
host: dc.host,
|
|
18894
18141
|
port: dc.port,
|
|
18142
|
+
mac: dc.mac,
|
|
18143
|
+
transport: dc.mac ? "mac-telnet" : "ssh",
|
|
18144
|
+
address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
|
|
18895
18145
|
username: dc.username,
|
|
18896
|
-
authMode: dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
18146
|
+
authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
18897
18147
|
isDefault: name === cfg.defaultDevice,
|
|
18898
18148
|
description: dc.description,
|
|
18899
18149
|
status: getDeviceStatus(name),
|
|
18900
|
-
|
|
18150
|
+
history: getDeviceHistory(name),
|
|
18151
|
+
activity: activity.get(name) ?? {
|
|
18152
|
+
calls: 0,
|
|
18153
|
+
errors: 0,
|
|
18154
|
+
lastSeen: 0,
|
|
18155
|
+
avgMs: 0
|
|
18156
|
+
}
|
|
18901
18157
|
}));
|
|
18902
18158
|
return { server: SERVER_TAG, defaultDevice: cfg.defaultDevice, devices };
|
|
18903
18159
|
}
|
|
@@ -18972,7 +18228,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
18972
18228
|
hostname: cfg.host,
|
|
18973
18229
|
port: cfg.port,
|
|
18974
18230
|
idleTimeout: 0,
|
|
18975
|
-
fetch(req, srv) {
|
|
18231
|
+
async fetch(req, srv) {
|
|
18976
18232
|
const url = new URL(req.url);
|
|
18977
18233
|
if (url.pathname === "/health")
|
|
18978
18234
|
return new Response("OK");
|
|
@@ -18990,6 +18246,15 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
18990
18246
|
const db = getEventStore();
|
|
18991
18247
|
if (!db)
|
|
18992
18248
|
return json({ error: "recorder not active" }, 503);
|
|
18249
|
+
if (url.pathname === "/api/events" && req.method === "DELETE") {
|
|
18250
|
+
let body = {};
|
|
18251
|
+
try {
|
|
18252
|
+
body = await req.json();
|
|
18253
|
+
} catch {}
|
|
18254
|
+
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
|
|
18255
|
+
const removed = body.all === true ? db.clear() : db.delete(ids);
|
|
18256
|
+
return json({ removed, total: db.total() });
|
|
18257
|
+
}
|
|
18993
18258
|
if (url.pathname === "/api/devices") {
|
|
18994
18259
|
return json(devicesPayload(db));
|
|
18995
18260
|
}
|
|
@@ -19104,7 +18369,7 @@ function corsHeaders(origin, configured) {
|
|
|
19104
18369
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19105
18370
|
|
|
19106
18371
|
// src/prompts/index.ts
|
|
19107
|
-
import { readFileSync as
|
|
18372
|
+
import { readFileSync as readFileSync5, readdirSync } from "fs";
|
|
19108
18373
|
import { join as join5 } from "path";
|
|
19109
18374
|
import { z as z87 } from "zod";
|
|
19110
18375
|
function parseFrontmatter(raw) {
|
|
@@ -19176,7 +18441,7 @@ function registerPrompts(server) {
|
|
|
19176
18441
|
for (const file of files) {
|
|
19177
18442
|
let parsed;
|
|
19178
18443
|
try {
|
|
19179
|
-
parsed = parseFrontmatter(
|
|
18444
|
+
parsed = parseFrontmatter(readFileSync5(join5(PROMPTS_DIR, file), "utf8"));
|
|
19180
18445
|
} catch (e) {
|
|
19181
18446
|
logger.warn(`Skipping prompt ${file}: ${String(e)}`);
|
|
19182
18447
|
continue;
|
|
@@ -19208,7 +18473,7 @@ function registerPrompts(server) {
|
|
|
19208
18473
|
// package.json
|
|
19209
18474
|
var package_default = {
|
|
19210
18475
|
name: "@usex/mikrotik-mcp",
|
|
19211
|
-
version: "2.
|
|
18476
|
+
version: "2.4.0",
|
|
19212
18477
|
description: "Bun-native MCP server for MikroTik RouterOS \u2014 200+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
19213
18478
|
keywords: [
|
|
19214
18479
|
"ai",
|
|
@@ -19282,6 +18547,7 @@ var package_default = {
|
|
|
19282
18547
|
dependencies: {
|
|
19283
18548
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
|
19284
18549
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
18550
|
+
"@tikoci/centrs": "^0.1.0",
|
|
19285
18551
|
ssh2: "^1.17.0",
|
|
19286
18552
|
zod: "^4.4.3"
|
|
19287
18553
|
},
|
|
@@ -19525,8 +18791,8 @@ function warnIfPlaintextPasswordInContainer(anyPassword) {
|
|
|
19525
18791
|
function listTools() {
|
|
19526
18792
|
const risk = (a) => a.readOnlyHint ? "READ" : a.destructiveHint ? "DESTRUCTIVE" : "WRITE";
|
|
19527
18793
|
let total = 0;
|
|
19528
|
-
for (const
|
|
19529
|
-
for (const t of
|
|
18794
|
+
for (const mod of allToolModules) {
|
|
18795
|
+
for (const t of mod) {
|
|
19530
18796
|
total++;
|
|
19531
18797
|
process.stdout.write(`${risk(t.annotations).padEnd(12)} ${t.name.padEnd(34)} ${t.title}
|
|
19532
18798
|
`);
|