@usex/mikrotik-mcp 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1313 -75
- package/dist/index.js +1289 -41
- package/dist/ui/observability.html +2 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +15 -1
- package/schemas/tool-catalog.json +553 -2110
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/
|
|
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 =
|
|
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
|
|
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
|
|
538
|
-
|
|
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
|
|
1778
|
+
return await client.run(command);
|
|
543
1779
|
} finally {
|
|
544
|
-
|
|
1780
|
+
client.disconnect();
|
|
545
1781
|
}
|
|
546
1782
|
}
|
|
547
1783
|
async function executeMikrotikCommand(command, ctx) {
|
|
@@ -673,7 +1909,7 @@ function parseFlagLegend(text) {
|
|
|
673
1909
|
if (!line)
|
|
674
1910
|
return out;
|
|
675
1911
|
const body = line.replace(/^\s*Flags:\s*/, "");
|
|
676
|
-
for (const part of body.split(
|
|
1912
|
+
for (const part of body.split(/[;,]/)) {
|
|
677
1913
|
const m = part.match(/^\s*([A-Za-z])\s*-\s*(.+?)\s*$/);
|
|
678
1914
|
if (m)
|
|
679
1915
|
out[m[1]] = m[2];
|
|
@@ -695,7 +1931,7 @@ function parseKvTokens(chunk) {
|
|
|
695
1931
|
return out;
|
|
696
1932
|
}
|
|
697
1933
|
var INDEX_LINE = /^\s*(\d+)\s+(.*)$/;
|
|
698
|
-
var LEADING_FLAGS = /^([A-
|
|
1934
|
+
var LEADING_FLAGS = /^([A-Z]+)(?=\s|$)/;
|
|
699
1935
|
function unionColumns(rows) {
|
|
700
1936
|
const seen = new Set;
|
|
701
1937
|
const cols = [];
|
|
@@ -718,6 +1954,8 @@ function parseDetailRecords(lines) {
|
|
|
718
1954
|
const row = { "#": current.index };
|
|
719
1955
|
if (current.flags.trim())
|
|
720
1956
|
row.flags = current.flags.trim();
|
|
1957
|
+
if (current.comment.trim())
|
|
1958
|
+
row.comment = current.comment.trim();
|
|
721
1959
|
Object.assign(row, parseKvTokens(current.chunk));
|
|
722
1960
|
rows.push(row);
|
|
723
1961
|
current = null;
|
|
@@ -726,11 +1964,28 @@ function parseDetailRecords(lines) {
|
|
|
726
1964
|
const m = line.match(INDEX_LINE);
|
|
727
1965
|
if (m) {
|
|
728
1966
|
flush();
|
|
729
|
-
|
|
1967
|
+
let rest = m[2];
|
|
1968
|
+
let flags = "";
|
|
730
1969
|
const fm = rest.match(LEADING_FLAGS);
|
|
731
|
-
|
|
1970
|
+
if (fm) {
|
|
1971
|
+
flags = fm[1];
|
|
1972
|
+
rest = rest.slice(fm[0].length).replace(/^\s+/, "");
|
|
1973
|
+
}
|
|
1974
|
+
let comment = "";
|
|
1975
|
+
const cm = rest.match(/;;;\s*(.*)$/);
|
|
1976
|
+
if (cm) {
|
|
1977
|
+
comment = cm[1];
|
|
1978
|
+
rest = rest.slice(0, cm.index).replace(/\s+$/, "");
|
|
1979
|
+
}
|
|
1980
|
+
current = { index: m[1], flags, comment, chunk: rest };
|
|
732
1981
|
} else if (current) {
|
|
733
|
-
|
|
1982
|
+
const cm = line.match(/;;;\s*(.*)$/);
|
|
1983
|
+
if (cm) {
|
|
1984
|
+
current.comment = `${current.comment} ${cm[1]}`.trim();
|
|
1985
|
+
current.chunk += ` ${line.slice(0, cm.index)}`;
|
|
1986
|
+
} else {
|
|
1987
|
+
current.chunk += ` ${line}`;
|
|
1988
|
+
}
|
|
734
1989
|
}
|
|
735
1990
|
}
|
|
736
1991
|
flush();
|
|
@@ -775,7 +2030,7 @@ function parseColumnarRecords(lines) {
|
|
|
775
2030
|
}
|
|
776
2031
|
function parseRecords(text) {
|
|
777
2032
|
const lines = text.split(`
|
|
778
|
-
`).filter((l) => !/^\s*Flags:/.test(l));
|
|
2033
|
+
`).filter((l) => !/^\s*(Flags|Columns):/.test(l));
|
|
779
2034
|
const hasKv = /[A-Za-z][\w.-]*=/.test(text);
|
|
780
2035
|
if (hasKv && lines.some((l) => INDEX_LINE.test(l))) {
|
|
781
2036
|
const rows = parseDetailRecords(lines);
|
|
@@ -1095,8 +2350,8 @@ function defineTool(def) {
|
|
|
1095
2350
|
function registerTools(server, modules, opts = {}) {
|
|
1096
2351
|
let count = 0;
|
|
1097
2352
|
const seen = new Set;
|
|
1098
|
-
for (const
|
|
1099
|
-
for (const tool of
|
|
2353
|
+
for (const mod2 of modules) {
|
|
2354
|
+
for (const tool of mod2) {
|
|
1100
2355
|
if (seen.has(tool.name)) {
|
|
1101
2356
|
throw new Error(`Duplicate tool name registered: ${tool.name}`);
|
|
1102
2357
|
}
|
|
@@ -1231,7 +2486,7 @@ ${result}`;
|
|
|
1231
2486
|
];
|
|
1232
2487
|
|
|
1233
2488
|
// src/core/ui-resources.ts
|
|
1234
|
-
import { readFileSync as
|
|
2489
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1235
2490
|
import { join as join3 } from "path";
|
|
1236
2491
|
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
1237
2492
|
|
|
@@ -1278,7 +2533,7 @@ function registerUiResources(server) {
|
|
|
1278
2533
|
registerAppResource(server, view.name, uri, { description: view.description, mimeType: RESOURCE_MIME_TYPE }, () => {
|
|
1279
2534
|
let html;
|
|
1280
2535
|
try {
|
|
1281
|
-
html =
|
|
2536
|
+
html = readFileSync4(file, "utf8");
|
|
1282
2537
|
} catch {
|
|
1283
2538
|
html = placeholderHtml(view);
|
|
1284
2539
|
}
|
|
@@ -1316,12 +2571,10 @@ var appViewTools = [
|
|
|
1316
2571
|
async handler(_a, ctx) {
|
|
1317
2572
|
const device = resolveDeviceName(ctx.device);
|
|
1318
2573
|
ctx.info(`Building system dashboard for '${device}'`);
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
printRecord("/system routerboard print", ctx)
|
|
1324
|
-
]);
|
|
2574
|
+
const identity = await printRecord("/system identity print", ctx);
|
|
2575
|
+
const resource = await printRecord("/system resource print", ctx);
|
|
2576
|
+
const health = await printRecord("/system health print", ctx);
|
|
2577
|
+
const routerboard = await printRecord("/system routerboard print", ctx);
|
|
1325
2578
|
const memTotal = parseSizeToBytes(resource["total-memory"]);
|
|
1326
2579
|
const memFree = parseSizeToBytes(resource["free-memory"]);
|
|
1327
2580
|
const memUsed = memTotal !== null && memFree !== null ? memTotal - memFree : null;
|
|
@@ -17246,7 +18499,7 @@ var moduleCatalog = [
|
|
|
17246
18499
|
var allToolModules = moduleCatalog.map((m) => m.tools);
|
|
17247
18500
|
|
|
17248
18501
|
// src/observability/dashboard.ts
|
|
17249
|
-
import { readFileSync as
|
|
18502
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
17250
18503
|
import { join as join4 } from "path";
|
|
17251
18504
|
var {serve } = globalThis.Bun;
|
|
17252
18505
|
|
|
@@ -17259,30 +18512,24 @@ function getDeviceStatus(name) {
|
|
|
17259
18512
|
return statuses.get(name) ?? UNKNOWN;
|
|
17260
18513
|
}
|
|
17261
18514
|
async function probeDevice(name, dc) {
|
|
17262
|
-
const
|
|
17263
|
-
|
|
17264
|
-
username: dc.username,
|
|
17265
|
-
password: dc.password,
|
|
17266
|
-
port: dc.port,
|
|
17267
|
-
keyFilename: dc.keyFilename,
|
|
17268
|
-
privateKey: dc.privateKey,
|
|
17269
|
-
keyPassphrase: dc.keyPassphrase,
|
|
18515
|
+
const client = createDeviceClient({
|
|
18516
|
+
...dc,
|
|
17270
18517
|
timeoutMs: Math.min(dc.timeoutMs ?? 1e4, 8000)
|
|
17271
18518
|
});
|
|
17272
18519
|
const t0 = Date.now();
|
|
17273
18520
|
let status;
|
|
17274
18521
|
try {
|
|
17275
|
-
const ok = await
|
|
18522
|
+
const ok = await client.connect();
|
|
17276
18523
|
if (!ok) {
|
|
17277
18524
|
status = {
|
|
17278
18525
|
reachable: false,
|
|
17279
18526
|
checkedAt: Date.now(),
|
|
17280
18527
|
latencyMs: null,
|
|
17281
|
-
error:
|
|
18528
|
+
error: client.lastError ?? "connection failed"
|
|
17282
18529
|
};
|
|
17283
18530
|
} else {
|
|
17284
|
-
const identity = parseKeyValues(await
|
|
17285
|
-
const version = parseKeyValues(await
|
|
18531
|
+
const identity = parseKeyValues(await client.run("/system identity print")).name;
|
|
18532
|
+
const version = parseKeyValues(await client.run("/system resource print")).version;
|
|
17286
18533
|
status = {
|
|
17287
18534
|
reachable: true,
|
|
17288
18535
|
checkedAt: Date.now(),
|
|
@@ -17299,7 +18546,7 @@ async function probeDevice(name, dc) {
|
|
|
17299
18546
|
error: e instanceof Error ? e.message : String(e)
|
|
17300
18547
|
};
|
|
17301
18548
|
} finally {
|
|
17302
|
-
|
|
18549
|
+
client.disconnect();
|
|
17303
18550
|
}
|
|
17304
18551
|
statuses.set(name, status);
|
|
17305
18552
|
return status;
|
|
@@ -17572,7 +18819,7 @@ function json(body, status = 200) {
|
|
|
17572
18819
|
}
|
|
17573
18820
|
function dashboardHtml() {
|
|
17574
18821
|
try {
|
|
17575
|
-
return
|
|
18822
|
+
return readFileSync5(join4(UI_DIST_DIR, "observability.html"), "utf8");
|
|
17576
18823
|
} catch {
|
|
17577
18824
|
return `<!doctype html><meta charset=utf-8><body style="font:14px system-ui;padding:24px;background:#0b0d10;color:#e8eaed">
|
|
17578
18825
|
<h2>MikroTik MCP \u2014 Observability Dashboard</h2>
|
|
@@ -17670,10 +18917,10 @@ function sseResponse(transportLabel) {
|
|
|
17670
18917
|
let ping;
|
|
17671
18918
|
const stream = new ReadableStream({
|
|
17672
18919
|
start(controller) {
|
|
17673
|
-
const
|
|
18920
|
+
const enc2 = new TextEncoder;
|
|
17674
18921
|
const send = (text) => {
|
|
17675
18922
|
try {
|
|
17676
|
-
controller.enqueue(
|
|
18923
|
+
controller.enqueue(enc2.encode(text));
|
|
17677
18924
|
} catch {}
|
|
17678
18925
|
};
|
|
17679
18926
|
send(`event: hello
|
|
@@ -17857,7 +19104,7 @@ function corsHeaders(origin, configured) {
|
|
|
17857
19104
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17858
19105
|
|
|
17859
19106
|
// src/prompts/index.ts
|
|
17860
|
-
import { readFileSync as
|
|
19107
|
+
import { readFileSync as readFileSync6, readdirSync } from "fs";
|
|
17861
19108
|
import { join as join5 } from "path";
|
|
17862
19109
|
import { z as z87 } from "zod";
|
|
17863
19110
|
function parseFrontmatter(raw) {
|
|
@@ -17929,7 +19176,7 @@ function registerPrompts(server) {
|
|
|
17929
19176
|
for (const file of files) {
|
|
17930
19177
|
let parsed;
|
|
17931
19178
|
try {
|
|
17932
|
-
parsed = parseFrontmatter(
|
|
19179
|
+
parsed = parseFrontmatter(readFileSync6(join5(PROMPTS_DIR, file), "utf8"));
|
|
17933
19180
|
} catch (e) {
|
|
17934
19181
|
logger.warn(`Skipping prompt ${file}: ${String(e)}`);
|
|
17935
19182
|
continue;
|
|
@@ -17961,7 +19208,7 @@ function registerPrompts(server) {
|
|
|
17961
19208
|
// package.json
|
|
17962
19209
|
var package_default = {
|
|
17963
19210
|
name: "@usex/mikrotik-mcp",
|
|
17964
|
-
version: "2.
|
|
19211
|
+
version: "2.2.0",
|
|
17965
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.",
|
|
17966
19213
|
keywords: [
|
|
17967
19214
|
"ai",
|
|
@@ -18278,8 +19525,8 @@ function warnIfPlaintextPasswordInContainer(anyPassword) {
|
|
|
18278
19525
|
function listTools() {
|
|
18279
19526
|
const risk = (a) => a.readOnlyHint ? "READ" : a.destructiveHint ? "DESTRUCTIVE" : "WRITE";
|
|
18280
19527
|
let total = 0;
|
|
18281
|
-
for (const
|
|
18282
|
-
for (const t of
|
|
19528
|
+
for (const mod2 of allToolModules) {
|
|
19529
|
+
for (const t of mod2) {
|
|
18283
19530
|
total++;
|
|
18284
19531
|
process.stdout.write(`${risk(t.annotations).padEnd(12)} ${t.name.padEnd(34)} ${t.title}
|
|
18285
19532
|
`);
|
|
@@ -18293,9 +19540,9 @@ function listDevicesCli() {
|
|
|
18293
19540
|
const cfg = loadConfig();
|
|
18294
19541
|
for (const [name, d] of Object.entries(cfg.devices)) {
|
|
18295
19542
|
const tag = name === cfg.defaultDevice ? " (default)" : "";
|
|
18296
|
-
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";
|
|
18297
19544
|
const desc = d.description ? ` \u2014 ${d.description}` : "";
|
|
18298
|
-
process.stdout.write(`${name}${tag} ${d.username}@${d
|
|
19545
|
+
process.stdout.write(`${name}${tag} ${d.username}@${describeTransport(d)} [auth: ${auth}]${desc}
|
|
18299
19546
|
`);
|
|
18300
19547
|
}
|
|
18301
19548
|
process.stdout.write(`
|
|
@@ -18303,31 +19550,22 @@ ${Object.keys(cfg.devices).length} device(s)
|
|
|
18303
19550
|
`);
|
|
18304
19551
|
}
|
|
18305
19552
|
async function probeDevice2(name, d) {
|
|
18306
|
-
const authMode = d.keyFilename || d.privateKey ? "SSH key" : "password";
|
|
18307
|
-
logger.info(`[${name}] Connecting to ${d.username}@${d
|
|
18308
|
-
const
|
|
18309
|
-
|
|
18310
|
-
|
|
18311
|
-
password: d.password,
|
|
18312
|
-
keyFilename: d.keyFilename,
|
|
18313
|
-
privateKey: d.privateKey,
|
|
18314
|
-
keyPassphrase: d.keyPassphrase,
|
|
18315
|
-
port: d.port,
|
|
18316
|
-
timeoutMs: d.timeoutMs
|
|
18317
|
-
});
|
|
18318
|
-
if (!await ssh.connect()) {
|
|
18319
|
-
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."}`);
|
|
18320
19558
|
return false;
|
|
18321
19559
|
}
|
|
18322
19560
|
try {
|
|
18323
|
-
const identity = (await
|
|
19561
|
+
const identity = (await client.run("/system identity print")).trim();
|
|
18324
19562
|
process.stdout.write(`
|
|
18325
19563
|
[${name}] Connection OK.
|
|
18326
19564
|
${identity}
|
|
18327
19565
|
`);
|
|
18328
19566
|
return true;
|
|
18329
19567
|
} finally {
|
|
18330
|
-
|
|
19568
|
+
client.disconnect();
|
|
18331
19569
|
}
|
|
18332
19570
|
}
|
|
18333
19571
|
async function authCheck() {
|