@usex/mikrotik-mcp 3.32.2 → 3.34.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/README.md +29 -14
- package/dist/cli.js +7 -5
- package/dist/index.d.ts +34 -3
- package/dist/index.js +5 -5
- package/dist/shared/{cli-ytn5v321.js → cli-g5kqcrt4.js} +1 -1
- package/dist/shared/{cli-t84nm0d4.js → cli-gt8xtjx9.js} +164 -79
- package/dist/shared/{library-bj9xrcg0.js → library-aa58hkb4.js} +164 -79
- package/dist/shared/{library-gsvkewg5.js → library-c629x3tn.js} +1 -1
- package/dist/ui/observability.html +4 -4
- package/package.json +4 -4
- package/schemas/config.schema.json +40 -0
|
@@ -25,6 +25,16 @@ var McpServerSettingsSchema = z.object({
|
|
|
25
25
|
toolPageSize: z.coerce.number().int().min(0).default(0),
|
|
26
26
|
appViews: z.boolean().default(true)
|
|
27
27
|
});
|
|
28
|
+
var JumpHostSchema = z.object({
|
|
29
|
+
host: z.string(),
|
|
30
|
+
port: z.coerce.number().int().positive().default(22),
|
|
31
|
+
username: z.string().default("admin"),
|
|
32
|
+
password: z.string().optional(),
|
|
33
|
+
keyFilename: z.string().optional(),
|
|
34
|
+
privateKey: z.string().optional(),
|
|
35
|
+
keyPassphrase: z.string().optional(),
|
|
36
|
+
timeoutMs: z.coerce.number().int().positive().optional()
|
|
37
|
+
});
|
|
28
38
|
var DeviceConfigSchema = z.object({
|
|
29
39
|
host: z.string().default("127.0.0.1"),
|
|
30
40
|
username: z.string().default("admin"),
|
|
@@ -34,6 +44,8 @@ var DeviceConfigSchema = z.object({
|
|
|
34
44
|
privateKey: z.string().optional(),
|
|
35
45
|
keyPassphrase: z.string().optional(),
|
|
36
46
|
timeoutMs: z.coerce.number().int().positive().default(1e4),
|
|
47
|
+
jumpVia: z.string().optional(),
|
|
48
|
+
jumpHost: JumpHostSchema.optional(),
|
|
37
49
|
mac: z.string().optional(),
|
|
38
50
|
sourceMac: z.string().optional(),
|
|
39
51
|
macHost: z.string().optional(),
|
|
@@ -128,6 +140,15 @@ var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
|
128
140
|
function loadConfig(argv = process.argv.slice(2)) {
|
|
129
141
|
const flags = parseFlags(argv);
|
|
130
142
|
const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
|
|
143
|
+
const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
|
|
144
|
+
const jumpHost = jumpHostName ? {
|
|
145
|
+
host: jumpHostName,
|
|
146
|
+
port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
|
|
147
|
+
username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
|
|
148
|
+
password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
|
|
149
|
+
keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
|
|
150
|
+
keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
|
|
151
|
+
} : undefined;
|
|
131
152
|
const single = {
|
|
132
153
|
host: pick("host", "MIKROTIK_HOST"),
|
|
133
154
|
username: pick("username", "MIKROTIK_USERNAME"),
|
|
@@ -137,6 +158,7 @@ function loadConfig(argv = process.argv.slice(2)) {
|
|
|
137
158
|
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
138
159
|
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
139
160
|
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
161
|
+
jumpHost,
|
|
140
162
|
mac: pick("mac", "MIKROTIK_MAC"),
|
|
141
163
|
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
142
164
|
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
@@ -703,45 +725,72 @@ function decodeOutput(data) {
|
|
|
703
725
|
|
|
704
726
|
class MikroTikSSHClient {
|
|
705
727
|
client = null;
|
|
728
|
+
bastions = [];
|
|
706
729
|
opts;
|
|
707
730
|
lastError;
|
|
708
731
|
constructor(opts) {
|
|
709
732
|
this.opts = { port: 22, timeoutMs: 1e4, ...opts };
|
|
710
733
|
}
|
|
711
|
-
connect() {
|
|
734
|
+
async connect() {
|
|
712
735
|
this.lastError = undefined;
|
|
713
|
-
|
|
736
|
+
try {
|
|
737
|
+
const hops = [];
|
|
738
|
+
for (let j = this.opts.jump;j; j = j.jump)
|
|
739
|
+
hops.unshift(j);
|
|
740
|
+
const sequence = [...hops, this.opts];
|
|
741
|
+
let sock;
|
|
742
|
+
for (let i = 0;i < hops.length; i++) {
|
|
743
|
+
const client = await this.openClient(hops[i], sock);
|
|
744
|
+
this.bastions.push(client);
|
|
745
|
+
const next = sequence[i + 1];
|
|
746
|
+
sock = await this.forwardOut(client, next.host, next.port ?? 22);
|
|
747
|
+
}
|
|
748
|
+
this.client = await this.openClient(this.opts, sock);
|
|
749
|
+
return true;
|
|
750
|
+
} catch (e) {
|
|
751
|
+
this.lastError = e instanceof Error ? e.message : String(e);
|
|
752
|
+
logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
|
|
753
|
+
this.disconnect();
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
openClient(o, sock) {
|
|
758
|
+
return new Promise((resolve2, reject) => {
|
|
714
759
|
const client = new Client;
|
|
715
760
|
const cfg = {
|
|
716
|
-
host:
|
|
717
|
-
port:
|
|
718
|
-
username:
|
|
719
|
-
readyTimeout:
|
|
761
|
+
host: o.host,
|
|
762
|
+
port: o.port ?? 22,
|
|
763
|
+
username: o.username,
|
|
764
|
+
readyTimeout: o.timeoutMs ?? 1e4
|
|
720
765
|
};
|
|
721
|
-
if (
|
|
722
|
-
cfg.
|
|
723
|
-
|
|
766
|
+
if (sock)
|
|
767
|
+
cfg.sock = sock;
|
|
768
|
+
if (o.privateKey) {
|
|
769
|
+
cfg.privateKey = o.privateKey;
|
|
770
|
+
} else if (o.keyFilename) {
|
|
724
771
|
try {
|
|
725
|
-
cfg.privateKey = readFileSync2(
|
|
772
|
+
cfg.privateKey = readFileSync2(o.keyFilename);
|
|
726
773
|
} catch (e) {
|
|
727
|
-
|
|
728
|
-
logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
|
|
729
|
-
resolve2(false);
|
|
774
|
+
reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
|
|
730
775
|
return;
|
|
731
776
|
}
|
|
732
777
|
}
|
|
733
|
-
if (cfg.privateKey &&
|
|
734
|
-
cfg.passphrase =
|
|
735
|
-
if (
|
|
736
|
-
cfg.password =
|
|
737
|
-
client.on("ready", () =>
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
778
|
+
if (cfg.privateKey && o.keyPassphrase)
|
|
779
|
+
cfg.passphrase = o.keyPassphrase;
|
|
780
|
+
if (o.password)
|
|
781
|
+
cfg.password = o.password;
|
|
782
|
+
client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
forwardOut(via, host, port) {
|
|
786
|
+
return new Promise((resolve2, reject) => {
|
|
787
|
+
via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
|
|
788
|
+
if (err) {
|
|
789
|
+
reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ` + "If the jump router runs RouterOS, enable SSH TCP forwarding on it: " + "/ip ssh set forwarding-enabled=local (or both)."));
|
|
790
|
+
} else {
|
|
791
|
+
resolve2(stream);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
745
794
|
});
|
|
746
795
|
}
|
|
747
796
|
run(command, opts = {}) {
|
|
@@ -860,6 +909,12 @@ class MikroTikSSHClient {
|
|
|
860
909
|
} catch {}
|
|
861
910
|
this.client = null;
|
|
862
911
|
}
|
|
912
|
+
for (const b of this.bastions.reverse()) {
|
|
913
|
+
try {
|
|
914
|
+
b.end();
|
|
915
|
+
} catch {}
|
|
916
|
+
}
|
|
917
|
+
this.bastions = [];
|
|
863
918
|
}
|
|
864
919
|
}
|
|
865
920
|
|
|
@@ -867,6 +922,43 @@ class MikroTikSSHClient {
|
|
|
867
922
|
function isMacTelnetDevice(dc) {
|
|
868
923
|
return Boolean(dc.mac);
|
|
869
924
|
}
|
|
925
|
+
function sshOptionsOf(dc) {
|
|
926
|
+
return {
|
|
927
|
+
host: dc.host,
|
|
928
|
+
username: dc.username,
|
|
929
|
+
password: dc.password,
|
|
930
|
+
keyFilename: dc.keyFilename,
|
|
931
|
+
privateKey: dc.privateKey,
|
|
932
|
+
keyPassphrase: dc.keyPassphrase,
|
|
933
|
+
port: dc.port,
|
|
934
|
+
timeoutMs: dc.timeoutMs
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
function resolveJump(dc, seen = new Set) {
|
|
938
|
+
if (dc.jumpHost) {
|
|
939
|
+
return {
|
|
940
|
+
host: dc.jumpHost.host,
|
|
941
|
+
port: dc.jumpHost.port,
|
|
942
|
+
username: dc.jumpHost.username,
|
|
943
|
+
password: dc.jumpHost.password,
|
|
944
|
+
keyFilename: dc.jumpHost.keyFilename,
|
|
945
|
+
privateKey: dc.jumpHost.privateKey,
|
|
946
|
+
keyPassphrase: dc.jumpHost.keyPassphrase,
|
|
947
|
+
timeoutMs: dc.jumpHost.timeoutMs
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (!dc.jumpVia)
|
|
951
|
+
return;
|
|
952
|
+
if (seen.has(dc.jumpVia)) {
|
|
953
|
+
throw new Error(`SSH jump-host cycle detected at '${dc.jumpVia}' (a device can't jump through itself).`);
|
|
954
|
+
}
|
|
955
|
+
seen.add(dc.jumpVia);
|
|
956
|
+
const bastion = getDevice(dc.jumpVia);
|
|
957
|
+
if (isMacTelnetDevice(bastion)) {
|
|
958
|
+
throw new Error(`Jump host '${dc.jumpVia}' is a MAC-Telnet device; an SSH bastion must be reachable over SSH.`);
|
|
959
|
+
}
|
|
960
|
+
return { ...sshOptionsOf(bastion), jump: resolveJump(bastion, seen) };
|
|
961
|
+
}
|
|
870
962
|
function createDeviceClient(dc) {
|
|
871
963
|
if (isMacTelnetDevice(dc)) {
|
|
872
964
|
return new MikroTikMacTelnetClient({
|
|
@@ -879,16 +971,7 @@ function createDeviceClient(dc) {
|
|
|
879
971
|
timeoutMs: dc.timeoutMs
|
|
880
972
|
});
|
|
881
973
|
}
|
|
882
|
-
return new MikroTikSSHClient({
|
|
883
|
-
host: dc.host,
|
|
884
|
-
username: dc.username,
|
|
885
|
-
password: dc.password,
|
|
886
|
-
keyFilename: dc.keyFilename,
|
|
887
|
-
privateKey: dc.privateKey,
|
|
888
|
-
keyPassphrase: dc.keyPassphrase,
|
|
889
|
-
port: dc.port,
|
|
890
|
-
timeoutMs: dc.timeoutMs
|
|
891
|
-
});
|
|
974
|
+
return new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
892
975
|
}
|
|
893
976
|
function connectErrorMessage(name, dc, lastError) {
|
|
894
977
|
const reason = lastError ? ` \u2014 ${lastError}` : "";
|
|
@@ -4402,40 +4485,39 @@ var changePlanTools = [
|
|
|
4402
4485
|
return "No commands provided to apply.";
|
|
4403
4486
|
const device = resolveDeviceName(ctx.device);
|
|
4404
4487
|
if (getDevice(ctx.device).mac) {
|
|
4405
|
-
|
|
4488
|
+
throw new Error("Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.");
|
|
4406
4489
|
}
|
|
4407
4490
|
const plan = buildChangePlan(commands);
|
|
4408
4491
|
const safe = getSafeModeManager(device);
|
|
4409
4492
|
const enabled2 = await safe.enable();
|
|
4410
4493
|
if (enabled2.startsWith("Error"))
|
|
4411
|
-
|
|
4494
|
+
throw new Error(enabled2);
|
|
4412
4495
|
const APPLY_BUDGET_MS = 90000;
|
|
4413
4496
|
const startedAt = Date.now();
|
|
4414
4497
|
const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4498
|
+
const applyAndDiff = async () => {
|
|
4499
|
+
try {
|
|
4500
|
+
const before = normalizeExport(await safe.execute("/export terse"));
|
|
4501
|
+
const log = [];
|
|
4502
|
+
for (const step of plan.steps) {
|
|
4503
|
+
if (overBudget()) {
|
|
4504
|
+
throw new Error(`aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded). ` + "Safe Mode is slow/unresponsive on this device; apply the change with the direct write tools instead");
|
|
4505
|
+
}
|
|
4506
|
+
const out = await safe.execute(step.command);
|
|
4507
|
+
if (looksLikeError(out)) {
|
|
4508
|
+
throw new Error(`step ${step.index} failed:
|
|
4509
|
+
${step.command}
|
|
4510
|
+
\u2192 ${out.trim()}`);
|
|
4511
|
+
}
|
|
4512
|
+
log.push(` \u2713 ${step.command}`);
|
|
4429
4513
|
}
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
const reachable = !looksLikeError(await safe.execute("/system identity print"));
|
|
4435
|
-
const planBody = renderPlan(plan).split(`
|
|
4514
|
+
const after = normalizeExport(await safe.execute("/export terse"));
|
|
4515
|
+
const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
|
|
4516
|
+
const reachable2 = !looksLikeError(await safe.execute("/system identity print"));
|
|
4517
|
+
const planBody = renderPlan(plan).split(`
|
|
4436
4518
|
`).slice(2).join(`
|
|
4437
4519
|
`);
|
|
4438
|
-
|
|
4520
|
+
const header2 = `APPLY PLAN \u2014 ${device}
|
|
4439
4521
|
|
|
4440
4522
|
${planBody}
|
|
4441
4523
|
|
|
@@ -4445,32 +4527,35 @@ ${log.join(`
|
|
|
4445
4527
|
|
|
4446
4528
|
` + `EXACT DIFF (+${diff.summary.added}/-${diff.summary.removed}):
|
|
4447
4529
|
${diff.unified || "(no config change)"}`;
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4530
|
+
return { header: header2, reachable: reachable2 };
|
|
4531
|
+
} catch (e) {
|
|
4532
|
+
await safe.rollback().catch(() => {});
|
|
4533
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
4534
|
+
throw new Error(`apply_plan failed and was rolled back (nothing committed): ${msg}`);
|
|
4535
|
+
}
|
|
4536
|
+
};
|
|
4537
|
+
const { header, reachable } = await applyAndDiff();
|
|
4538
|
+
if (!a.confirm) {
|
|
4539
|
+
await safe.rollback();
|
|
4540
|
+
return `${header}
|
|
4451
4541
|
|
|
4452
4542
|
DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4543
|
+
}
|
|
4544
|
+
if (!reachable) {
|
|
4545
|
+
await safe.rollback();
|
|
4546
|
+
throw new Error(`${header}
|
|
4457
4547
|
|
|
4458
|
-
ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4548
|
+
ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`);
|
|
4549
|
+
}
|
|
4550
|
+
const committed = await safe.commit();
|
|
4551
|
+
if (!committed.ok) {
|
|
4552
|
+
throw new Error(`${header}
|
|
4463
4553
|
|
|
4464
|
-
COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode).
|
|
4465
|
-
|
|
4466
|
-
|
|
4554
|
+
COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ${committed.message}`);
|
|
4555
|
+
}
|
|
4556
|
+
return `${header}
|
|
4467
4557
|
|
|
4468
4558
|
COMMITTED: changes are now permanent. ${committed.message}`;
|
|
4469
|
-
} catch (e) {
|
|
4470
|
-
await safe.rollback();
|
|
4471
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
4472
|
-
return `apply_plan failed and was rolled back: ${msg}`;
|
|
4473
|
-
}
|
|
4474
4559
|
}
|
|
4475
4560
|
})
|
|
4476
4561
|
];
|
|
@@ -5847,7 +5932,7 @@ var cache = null;
|
|
|
5847
5932
|
async function gateway() {
|
|
5848
5933
|
if (cache)
|
|
5849
5934
|
return cache;
|
|
5850
|
-
const { moduleCatalog } = await import("./library-
|
|
5935
|
+
const { moduleCatalog } = await import("./library-c629x3tn.js");
|
|
5851
5936
|
const forIndex = [];
|
|
5852
5937
|
const byName = new Map;
|
|
5853
5938
|
for (const mod of moduleCatalog) {
|