@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.
@@ -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
- return new Promise((resolve2) => {
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: this.opts.host,
717
- port: this.opts.port,
718
- username: this.opts.username,
719
- readyTimeout: this.opts.timeoutMs
761
+ host: o.host,
762
+ port: o.port ?? 22,
763
+ username: o.username,
764
+ readyTimeout: o.timeoutMs ?? 1e4
720
765
  };
721
- if (this.opts.privateKey) {
722
- cfg.privateKey = this.opts.privateKey;
723
- } else if (this.opts.keyFilename) {
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(this.opts.keyFilename);
772
+ cfg.privateKey = readFileSync2(o.keyFilename);
726
773
  } catch (e) {
727
- this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
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 && this.opts.keyPassphrase)
734
- cfg.passphrase = this.opts.keyPassphrase;
735
- if (this.opts.password)
736
- cfg.password = this.opts.password;
737
- client.on("ready", () => {
738
- this.client = client;
739
- resolve2(true);
740
- }).on("error", (err) => {
741
- this.lastError = err.message;
742
- logger.error(`Failed to connect to MikroTik: ${err.message}`);
743
- resolve2(false);
744
- }).connect(cfg);
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
- return "Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.";
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
- return enabled2;
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
- try {
4416
- const before = normalizeExport(await safe.execute("/export terse"));
4417
- const log = [];
4418
- for (const step of plan.steps) {
4419
- if (overBudget()) {
4420
- await safe.rollback();
4421
- return `Aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded) \u2014 ` + "the plan was ROLLED BACK (nothing committed). Safe Mode is slow/unresponsive on this " + "device; apply the change with the direct write tools instead.";
4422
- }
4423
- const out = await safe.execute(step.command);
4424
- if (looksLikeError(out)) {
4425
- await safe.rollback();
4426
- return `Step ${step.index} failed and the plan was ROLLED BACK (nothing committed):
4427
- ` + ` ${step.command}
4428
- \u2192 ${out.trim()}`;
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
- log.push(` \u2713 ${step.command}`);
4431
- }
4432
- const after = normalizeExport(await safe.execute("/export terse"));
4433
- const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
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
- const header = `APPLY PLAN \u2014 ${device}
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
- if (!a.confirm) {
4449
- await safe.rollback();
4450
- return `${header}
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
- if (!reachable) {
4455
- await safe.rollback();
4456
- return `${header}
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
- const committed = await safe.commit();
4461
- if (!committed.ok) {
4462
- return `${header}
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). ` + `${committed.message}`;
4465
- }
4466
- return `${header}
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-gsvkewg5.js");
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) {
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./library-bj9xrcg0.js";
7
+ } from "./library-aa58hkb4.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,