@usex/mikrotik-mcp 5.5.0 → 5.6.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.
@@ -42,6 +42,7 @@ var logger = {
42
42
 
43
43
  // src/ssh/client.ts
44
44
  var RUN_IDLE_TIMEOUT_MS = 60000;
45
+ var RUN_HARD_TIMEOUT_MS = 120000;
45
46
  function decodeOutput(data) {
46
47
  if (!data || data.length === 0)
47
48
  return "";
@@ -158,11 +159,14 @@ class MikroTikSSHClient {
158
159
  let settled = false;
159
160
  let timer;
160
161
  let idleTimer;
162
+ let hardTimer;
161
163
  const clearTimers = () => {
162
164
  if (timer)
163
165
  clearTimeout(timer);
164
166
  if (idleTimer)
165
167
  clearTimeout(idleTimer);
168
+ if (hardTimer)
169
+ clearTimeout(hardTimer);
166
170
  };
167
171
  const finish = () => {
168
172
  if (settled)
@@ -204,6 +208,14 @@ class MikroTikSSHClient {
204
208
  } catch {}
205
209
  finish();
206
210
  }, opts.maxMs);
211
+ } else {
212
+ hardTimer = setTimeout(() => {
213
+ try {
214
+ stream.signal("INT");
215
+ } catch {}
216
+ const got = Buffer.concat(stdout).length;
217
+ fail(new Error(`MikroTik command exceeded the ${RUN_HARD_TIMEOUT_MS / 1000}s hard timeout and was ` + `aborted (${got} bytes received, output discarded as incomplete). ` + `Command: ${command.slice(0, 120)}`));
218
+ }, RUN_HARD_TIMEOUT_MS);
207
219
  }
208
220
  stream.on("close", finish).on("data", (d) => {
209
221
  stdout.push(d);
@@ -769,20 +781,36 @@ function deviceLabels() {
769
781
  }
770
782
  return out;
771
783
  }
772
- function resolveDeviceName(name) {
773
- if (name) {
774
- if (name in active.devices && isEnabled(active.devices[name]))
775
- return name;
776
- const byLabel = deviceKeyForLabel(name);
777
- if (byLabel)
778
- return byLabel;
779
- }
784
+ function defaultDeviceKey() {
780
785
  if (active.defaultDevice in active.devices && isEnabled(active.devices[active.defaultDevice])) {
781
786
  return active.defaultDevice;
782
787
  }
783
788
  const firstEnabled = Object.entries(active.devices).find(([, dc]) => isEnabled(dc));
784
789
  return firstEnabled ? firstEnabled[0] : active.defaultDevice;
785
790
  }
791
+ function tryResolveDeviceName(name) {
792
+ if (!name)
793
+ return;
794
+ if (name in active.devices && isEnabled(active.devices[name]))
795
+ return name;
796
+ return deviceKeyForLabel(name);
797
+ }
798
+ function resolveDeviceName(name) {
799
+ if (!name)
800
+ return defaultDeviceKey();
801
+ const resolved = tryResolveDeviceName(name);
802
+ if (resolved)
803
+ return resolved;
804
+ throw new Error(unknownDeviceMessage(name));
805
+ }
806
+ function unknownDeviceMessage(name) {
807
+ const enabled = listDevices().names;
808
+ const disabled = name in active.devices && !isEnabled(active.devices[name]);
809
+ if (disabled) {
810
+ return `Device '${name}' is disabled. Enable it from the dashboard or config file. Enabled devices: ${enabled.join(", ")}`;
811
+ }
812
+ return `Unknown device '${name}'. Enabled devices: ${enabled.join(", ") || "(none)"}. ` + "Call list_mikrotik_devices for the authoritative current set \u2014 this was NOT run against " + "the default device.";
813
+ }
786
814
  function deviceTarget(dc) {
787
815
  if (!dc)
788
816
  return "?";
@@ -802,9 +830,6 @@ function resolvedTarget(name) {
802
830
  return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
803
831
  }
804
832
  function getDevice(name) {
805
- if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
806
- throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
807
- }
808
833
  const key = resolveDeviceName(name);
809
834
  const dc = active.devices[key];
810
835
  if (!dc)
@@ -2209,6 +2234,27 @@ function parseDetailRecords(lines) {
2209
2234
  flush();
2210
2235
  return rows;
2211
2236
  }
2237
+ function filterDetailBlocks(text, predicate) {
2238
+ const lines = text.split(/\r?\n/);
2239
+ const firstRecord = lines.findIndex((l) => INDEX_LINE.test(l));
2240
+ if (firstRecord === -1)
2241
+ return text;
2242
+ const preamble = lines.slice(0, firstRecord);
2243
+ const blocks = [];
2244
+ for (const line of lines.slice(firstRecord)) {
2245
+ if (INDEX_LINE.test(line) || blocks.length === 0)
2246
+ blocks.push([]);
2247
+ blocks[blocks.length - 1].push(line);
2248
+ }
2249
+ const kept = blocks.filter((block) => {
2250
+ const [row] = parseDetailRecords(block);
2251
+ return row ? predicate(row) : false;
2252
+ });
2253
+ if (kept.length === 0)
2254
+ return "";
2255
+ return [...preamble, ...kept.flat()].join(`
2256
+ `);
2257
+ }
2212
2258
  function parseColumnarRecords(lines) {
2213
2259
  const headerIdx = lines.findIndex((l) => /\S/.test(l) && !l.includes("=") && /^[\s#]*[#A-Z]/.test(l) && /[A-Z]/.test(l));
2214
2260
  if (headerIdx === -1)
@@ -2579,8 +2625,20 @@ function portConflictError(result, port) {
2579
2625
  if (!/configured elsewhere/i.test(result))
2580
2626
  return;
2581
2627
  const other = result.match(/\*\d+\s*=\s*([A-Za-z][\w-]*)/)?.[1];
2582
- const which = port != null ? `port ${port}` : "that port";
2583
- return other ? `${which} is already used by the '${other}' service \u2014 two services can't share a port. Pick a different port, or change/disable '${other}' first (disable_ip_service / set_ip_service).` : `${which} is already in use elsewhere \u2014 two items can't share it. Pick a different port, or free it on the conflicting item first.`;
2628
+ if (port == null) {
2629
+ return `the device rejected this change with 'configured elsewhere' \u2014 the command targeted more ` + `than one row, or the value is owned by another item${other ? ` (RouterOS named '${other}')` : ""}. No port was being changed, so this is not a port collision.`;
2630
+ }
2631
+ return other ? `port ${port} is already used by the '${other}' service \u2014 two services can't share a port. Pick a different port, or change/disable '${other}' first (disable_ip_service / set_ip_service).` : `port ${port} is already in use elsewhere \u2014 two items can't share it. Pick a different port, or free it on the conflicting item first.`;
2632
+ }
2633
+ function interfaceValueError(result, fields) {
2634
+ if (!/input does not match any value of interface/i.test(result))
2635
+ return;
2636
+ const sent = fields.filter((f) => f.value !== undefined && f.value !== "");
2637
+ if (sent.length === 0)
2638
+ return;
2639
+ const which = sent.map((f) => `${f.name}='${f.value ?? ""}'`).join(", ");
2640
+ const listHint = sent.map((f) => `${f.name}_list`).join(" / ");
2641
+ return `the device rejected an interface value (${which}) \u2014 it is not the name of an interface ` + `on this router. If you meant an interface LIST (e.g. WAN, LAN), use ${listHint} instead; ` + "list_interfaces shows the valid interface names.";
2584
2642
  }
2585
2643
  function commandUnsupported(result) {
2586
2644
  const t = result.toLowerCase();
@@ -4399,6 +4457,18 @@ function gatingDecision(requires, multiDevice) {
4399
4457
  }
4400
4458
  return { prefix: `[unavailable on this device: ${unmet}]` };
4401
4459
  }
4460
+ var HTML_ENTITIES = /&(amp|lt|gt|quot|#0?39|apos);/i;
4461
+ function htmlEscapedArgument(args) {
4462
+ for (const [key, value] of Object.entries(args)) {
4463
+ if (typeof value !== "string")
4464
+ continue;
4465
+ const hit = value.match(HTML_ENTITIES);
4466
+ if (!hit)
4467
+ continue;
4468
+ return `'${key}' contains HTML-escaped text (${hit[0]}). RouterOS stores it literally, so the ` + `value would be saved with "${hit[0]}" in it and every later exact lookup ` + `(e.g. [find comment="\u2026"]) would fail to match. Send the character itself ` + `(&, <, >, ", ') \u2014 quoting and escaping for the console is handled automatically.`;
4469
+ }
4470
+ return;
4471
+ }
4402
4472
  function defineTool(def) {
4403
4473
  return {
4404
4474
  name: def.name,
@@ -4432,6 +4502,21 @@ function defineTool(def) {
4432
4502
  const callback = async (args) => {
4433
4503
  const { device, reason, ...rest } = args;
4434
4504
  const deviceName = typeof device === "string" ? device : undefined;
4505
+ if (!def.noDevice && deviceName !== undefined && !tryResolveDeviceName(deviceName)) {
4506
+ const text = `Error: ${unknownDeviceMessage(deviceName)}`;
4507
+ logger.error(text);
4508
+ sendLog?.("error", text);
4509
+ return { content: [{ type: "text", text }], isError: true };
4510
+ }
4511
+ if (risk !== "READ") {
4512
+ const escaped = htmlEscapedArgument(rest);
4513
+ if (escaped) {
4514
+ const text = `Error: ${escaped}`;
4515
+ logger.error(text);
4516
+ sendLog?.("error", text);
4517
+ return { content: [{ type: "text", text }], isError: true };
4518
+ }
4519
+ }
4435
4520
  const policy = getAccessPolicy();
4436
4521
  if (policy.enabled) {
4437
4522
  const resolvedDevice = def.noDevice ? undefined : resolveDeviceName(deviceName);
@@ -8434,7 +8519,12 @@ var complianceAuditTools = [
8434
8519
  results.push({ device: resolved, report, text });
8435
8520
  for (const e of report.evaluatedChecks) {
8436
8521
  if (e.result.status === "fail" || e.result.status === "warn") {
8437
- failTally.set(e.check.id, (failTally.get(e.check.id) ?? 0) + 1);
8522
+ const prev = failTally.get(e.check.id);
8523
+ failTally.set(e.check.id, {
8524
+ count: (prev?.count ?? 0) + 1,
8525
+ label: e.result.label,
8526
+ status: prev?.status === "fail" ? "fail" : e.result.status
8527
+ });
8438
8528
  }
8439
8529
  }
8440
8530
  } catch (err) {
@@ -8480,11 +8570,12 @@ Error: ${err instanceof Error ? err.message : String(err)}`
8480
8570
  lines.push("");
8481
8571
  }
8482
8572
  if (failTally.size > 0) {
8483
- const sorted = [...failTally.entries()].sort((a2, b) => b[1] - a2[1]).slice(0, 10);
8573
+ const sorted = [...failTally.entries()].sort((a2, b) => b[1].count - a2[1].count).slice(0, 10);
8484
8574
  lines.push("\u2500\u2500 MOST COMMON FAILURES \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
8485
- for (const [id, count] of sorted) {
8575
+ for (const [id, { count, label, status }] of sorted) {
8486
8576
  const check = ALL_CHECKS.find((c) => c.id === id);
8487
- lines.push(` ${String(count).padStart(2)}/${targets.length} devices ${id.padEnd(30)} ${check?.title ?? ""}`);
8577
+ const want = check?.title ? ` (should be: ${check.title})` : "";
8578
+ lines.push(` ${String(count).padStart(2)}/${targets.length} devices ${status.toUpperCase().padEnd(4)} ${id.padEnd(30)} ${label}${want}`);
8488
8579
  }
8489
8580
  lines.push("");
8490
8581
  }
@@ -11010,7 +11101,7 @@ var cache2 = null;
11010
11101
  async function gateway() {
11011
11102
  if (cache2)
11012
11103
  return cache2;
11013
- const { moduleCatalog } = await import("./cli-qqwjbjnv.js");
11104
+ const { moduleCatalog } = await import("./cli-h41j14aw.js");
11014
11105
  const forIndex = [];
11015
11106
  const byName = new Map;
11016
11107
  for (const mod of moduleCatalog) {
@@ -16514,20 +16605,36 @@ Management access may be partially restricted \u2014 review immediately.`;
16514
16605
  import { z as z35 } from "zod";
16515
16606
 
16516
16607
  // src/tools/_resolve-rule-id.ts
16608
+ var VALID_ID = /^(?:\*[0-9a-fA-F]+|\d+)$/;
16609
+ function isValidItemId(id) {
16610
+ return VALID_ID.test(id.trim());
16611
+ }
16612
+ async function listItemIds(scope, ctx) {
16613
+ const idsRaw = await executeMikrotikCommand(`:foreach i in=[${scope} find] do={:put $i}`, ctx);
16614
+ if (isEmpty(idsRaw))
16615
+ return [];
16616
+ return idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
16617
+ }
16517
16618
  function ruleResolver(scope) {
16518
16619
  return async function resolveRuleId(ruleId, ctx) {
16519
- if (/^\d+$/.test(ruleId)) {
16520
- const idsRaw = await executeMikrotikCommand(`:foreach i in=[${scope} find] do={:put $i}`, ctx);
16521
- if (isEmpty(idsRaw))
16620
+ const id = ruleId.trim();
16621
+ if (!isValidItemId(id))
16622
+ return null;
16623
+ if (/^\d+$/.test(id)) {
16624
+ const ids = await listItemIds(scope, ctx);
16625
+ if (ids.length === 0)
16522
16626
  return null;
16523
- const ids = idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
16524
- const pos = Number.parseInt(ruleId, 10);
16627
+ const pos = Number.parseInt(id, 10);
16525
16628
  return pos >= 0 && pos < ids.length ? ids[pos] : null;
16526
16629
  }
16527
- const count = await executeMikrotikCommand(`${scope} print count-only where .id=${ruleId}`, ctx);
16528
- return count.trim() !== "0" ? ruleId : null;
16630
+ const count = await executeMikrotikCommand(`${scope} print count-only where .id=${id}`, ctx);
16631
+ return count.trim() !== "0" ? id : null;
16529
16632
  };
16530
16633
  }
16634
+ function notFoundMessage(what, id, listTool) {
16635
+ const shape = isValidItemId(id) ? "" : ` '${id}' is not a valid reference \u2014 pass the \`.id\` from ${listTool} (e.g. "*1F") or a bare row position (e.g. "3").`;
16636
+ return `${what} '${id}' not found.${shape}`;
16637
+ }
16531
16638
 
16532
16639
  // src/tools/firewall-filter.ts
16533
16640
  var isDigits = (s) => /^\d+$/.test(s);
@@ -16620,8 +16727,13 @@ async function updateFilterRule(a, ctx) {
16620
16727
  return `Firewall filter rule '${a.rule_id}' not found.`;
16621
16728
  const cmd = `/ip firewall filter set ${id} ${updates.join(" ")}`;
16622
16729
  const result = await executeMikrotikCommand(cmd, ctx);
16623
- if (looksLikeError(result))
16624
- return `Failed to update firewall filter rule: ${result}`;
16730
+ if (looksLikeError(result)) {
16731
+ const hint = interfaceValueError(result, [
16732
+ { name: "in_interface", value: a.in_interface },
16733
+ { name: "out_interface", value: a.out_interface }
16734
+ ]);
16735
+ return `Failed to update firewall filter rule: ${hint ?? result}`;
16736
+ }
16625
16737
  const details = await executeMikrotikCommand(`/ip firewall filter print detail where .id=${id}`, ctx);
16626
16738
  return `Firewall filter rule updated successfully:
16627
16739
 
@@ -16710,7 +16822,10 @@ var firewallFilterTools = [
16710
16822
  const result = await executeMikrotikCommand(cmd, ctx);
16711
16823
  const trimmed = result.trim();
16712
16824
  if (looksLikeError(trimmed)) {
16713
- const hint = placeBeforeError(trimmed, a.place_before);
16825
+ const hint = placeBeforeError(trimmed, a.place_before) ?? interfaceValueError(trimmed, [
16826
+ { name: "in_interface", value: a.in_interface },
16827
+ { name: "out_interface", value: a.out_interface }
16828
+ ]);
16714
16829
  return `Failed to create firewall filter rule: ${hint ?? trimmed}`;
16715
16830
  }
16716
16831
  const createdId = extractCreatedId(trimmed);
@@ -16758,18 +16873,31 @@ ${details}`;
16758
16873
  filters.push(`src-address~"${a.src_address_filter}"`);
16759
16874
  if (a.dst_address_filter)
16760
16875
  filters.push(`dst-address~"${a.dst_address_filter}"`);
16761
- if (a.protocol_filter)
16762
- filters.push(`protocol=${a.protocol_filter}`);
16763
- if (a.interface_filter) {
16764
- filters.push(`(in-interface~"${a.interface_filter}" or out-interface~"${a.interface_filter}")`);
16765
- }
16766
16876
  if (a.disabled_only)
16767
16877
  filters.push("disabled=yes");
16768
16878
  if (a.invalid_only)
16769
16879
  filters.push("invalid=yes");
16770
16880
  if (a.dynamic_only)
16771
16881
  filters.push("dynamic=yes");
16772
- const result = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
16882
+ const raw = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
16883
+ const protocolWanted = a.protocol_filter?.toLowerCase();
16884
+ const interfaceWanted = a.interface_filter?.toLowerCase();
16885
+ const result = protocolWanted || interfaceWanted ? filterDetailBlocks(raw, (row) => {
16886
+ if (protocolWanted && (row.protocol ?? "").toLowerCase() !== protocolWanted) {
16887
+ return false;
16888
+ }
16889
+ if (interfaceWanted) {
16890
+ const hit = [
16891
+ row["in-interface"],
16892
+ row["out-interface"],
16893
+ row["in-interface-list"],
16894
+ row["out-interface-list"]
16895
+ ].some((v) => (v ?? "").toLowerCase().includes(interfaceWanted));
16896
+ if (!hit)
16897
+ return false;
16898
+ }
16899
+ return true;
16900
+ }) : raw;
16773
16901
  return isEmpty(result) ? "No firewall filter rules found matching the criteria." : `FIREWALL FILTER RULES:
16774
16902
 
16775
16903
  ${result}`;
@@ -16779,7 +16907,7 @@ ${result}`;
16779
16907
  name: "get_filter_rule",
16780
16908
  title: "Get Firewall Filter Rule",
16781
16909
  annotations: READ,
16782
- description: "Gets the full detail of one IPv4 firewall FILTER rule (`/ip firewall filter`) by id \u2014 " + "every matcher, action, counter and flag. For IPv6 use get_ipv6_filter_rule. " + 'rule_id: preferably the `.id` from list_filter_rules e.g. "*1F". A bare number like "3" ' + "is tried as `.id=*3` first, then as the positional row index if no `.id` matches.",
16910
+ description: "Gets the full detail of one IPv4 firewall FILTER rule (`/ip firewall filter`) by id \u2014 " + "every matcher, action, counter and flag. For IPv6 use get_ipv6_filter_rule. " + 'rule_id: preferably the `.id` from list_filter_rules e.g. "*1F". A bare number like "3" ' + "is read as the positional ROW INDEX and resolved to whatever `.id` currently sits there \u2014 " + "it is never treated as `.id=*3`, since a `*N` id is hex and reassigned over time. Row " + "positions shift whenever a rule is added, removed or moved, so prefer the `.id`.",
16783
16911
  inputSchema: {
16784
16912
  rule_id: z35.string().describe('Rule .id e.g. "*1F", or bare position number e.g. "3"')
16785
16913
  },
@@ -16887,10 +17015,23 @@ ${result}`;
16887
17015
  const id = await resolveFilterRuleId(a.rule_id, ctx);
16888
17016
  if (!id)
16889
17017
  return `Firewall filter rule '${a.rule_id}' not found.`;
17018
+ const before = await listItemIds("/ip firewall filter", ctx);
16890
17019
  const result = await executeMikrotikCommand(`/ip firewall filter move ${id} destination=${a.destination}`, ctx);
16891
17020
  if (looksLikeError(result))
16892
17021
  return `Failed to move firewall filter rule: ${result}`;
16893
- return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${a.destination}.`;
17022
+ const after = await listItemIds("/ip firewall filter", ctx);
17023
+ const from = before.indexOf(id);
17024
+ const to = after.indexOf(id);
17025
+ if (to === -1) {
17026
+ return `Firewall filter rule ${id} moved, but it could not be located afterwards \u2014 verify with list_filter_rules.`;
17027
+ }
17028
+ if (to === a.destination) {
17029
+ return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${to}.`;
17030
+ }
17031
+ if (to === from) {
17032
+ return `NO CHANGE: firewall filter rule '${a.rule_id}' (${id}) is still at position ${to} \u2014 ` + `RouterOS ignored the move to ${a.destination}. Destination is 0-based and counts ` + `ALL rules in the menu (${after.length} total), not just this rule's chain; a ` + "destination at or past the end, or equal to the current position, does nothing.";
17033
+ }
17034
+ return `Firewall filter rule '${a.rule_id}' (${id}) moved from position ${from} to ${to} ` + `(requested ${a.destination} \u2014 RouterOS places the rule BEFORE the item currently at ` + "the destination index, so the final index can differ when moving downward).";
16894
17035
  }
16895
17036
  }),
16896
17037
  defineTool({
@@ -18630,6 +18771,7 @@ ${result}`;
18630
18771
 
18631
18772
  // src/tools/ip-service.ts
18632
18773
  import { z as z43 } from "zod";
18774
+ var staticService = (name) => `[find name="${name}" and !dynamic]`;
18633
18775
  var ipServiceTools = [
18634
18776
  defineTool({
18635
18777
  name: "list_ip_services",
@@ -18675,7 +18817,7 @@ ${result}`;
18675
18817
  },
18676
18818
  async handler(a, ctx) {
18677
18819
  ctx.info(`Updating IP service: name=${a.name}`);
18678
- const cmd = new Cmd(`/ip service set [find name="${a.name}"]`).opt("port", a.port).opt("address", a.address).bool("disabled", a.disabled).opt("certificate", a.certificate).opt("vrf", a.vrf).build();
18820
+ const cmd = new Cmd(`/ip service set ${staticService(a.name)}`).opt("port", a.port).opt("address", a.address).bool("disabled", a.disabled).opt("certificate", a.certificate).opt("vrf", a.vrf).build();
18679
18821
  if (!cmd.includes("=", cmd.indexOf("]")))
18680
18822
  return "No updates specified.";
18681
18823
  const result = await executeMikrotikCommand(cmd, ctx);
@@ -18699,7 +18841,7 @@ ${details}`;
18699
18841
  },
18700
18842
  async handler(a, ctx) {
18701
18843
  ctx.info(`Enabling IP service: name=${a.name}`);
18702
- const result = await executeMikrotikCommand(`/ip service enable [find name="${a.name}"]`, ctx);
18844
+ const result = await executeMikrotikCommand(`/ip service enable ${staticService(a.name)}`, ctx);
18703
18845
  if (looksLikeError(result))
18704
18846
  return `Failed to enable IP service: ${result}`;
18705
18847
  return `IP service '${a.name}' enabled successfully.`;
@@ -18715,7 +18857,7 @@ ${details}`;
18715
18857
  },
18716
18858
  async handler(a, ctx) {
18717
18859
  ctx.info(`Disabling IP service: name=${a.name}`);
18718
- const result = await executeMikrotikCommand(`/ip service disable [find name="${a.name}"]`, ctx);
18860
+ const result = await executeMikrotikCommand(`/ip service disable ${staticService(a.name)}`, ctx);
18719
18861
  if (looksLikeError(result))
18720
18862
  return `Failed to disable IP service: ${result}`;
18721
18863
  return `IP service '${a.name}' disabled successfully.`;
@@ -25212,7 +25354,7 @@ var speedTestTools = [
25212
25354
  },
25213
25355
  async handler(a, ctx) {
25214
25356
  ctx.info(`Speed test to ${a.address} (test-duration=${a.duration}s)`);
25215
- const cmd = new Cmd(`/tool speed-test address=${a.address}`).set("test-duration", `${a.duration}s`).opt("connection-count", a.connection_count).opt("user", a.user).opt("password", a.password).build();
25357
+ const cmd = new Cmd("/tool speed-test").set("address", a.address).set("test-duration", `${a.duration}s`).opt("connection-count", a.connection_count).opt("user", a.user).opt("password", a.password).build();
25216
25358
  const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.duration * 1000 + 15000 }));
25217
25359
  if (looksLikeError(result))
25218
25360
  return `Failed to run speed test to ${a.address}: ${result}`;
@@ -29895,10 +30037,11 @@ async function collectRoutes(ctx) {
29895
30037
  dst: r["dst-address"] ?? "",
29896
30038
  gateway: r.gateway ?? "",
29897
30039
  distance: num(r.distance),
29898
- active: (r.flags ?? "").includes("A") || r.active === "true",
29899
- dynamic: (r.flags ?? "").includes("D") || r.dynamic === "true"
30040
+ active: (r.flags ?? "").includes("A") || isYes(r.active),
30041
+ dynamic: (r.flags ?? "").includes("D") || isYes(r.dynamic)
29900
30042
  }));
29901
- const hasDefault = routes.some((r) => r.dst === "0.0.0.0/0" && r.active);
30043
+ const isDefaultDst = (dst) => dst === "0.0.0.0/0" || dst === "0.0.0.0/0*";
30044
+ const hasDefault = routes.some((r) => isDefaultDst(r.dst) && r.active);
29902
30045
  return { routes, count: routes.length, hasDefault };
29903
30046
  }
29904
30047
  async function collectOspfNeighbors(ctx) {
@@ -30409,6 +30552,8 @@ var multiwanTools = [
30409
30552
 
30410
30553
  // src/tools/routes.ts
30411
30554
  import { z as z99 } from "zod";
30555
+ var resolveRouteId = ruleResolver("/ip route");
30556
+ var routeNotFound = (id) => notFoundMessage("Route", id, "list_routes");
30412
30557
  var RouteType2 = z99.enum(["unicast", "blackhole"]);
30413
30558
  async function addRoute(a, ctx) {
30414
30559
  ctx.info(`Adding route: dst=${a.dst_address}, gateway=${a.gateway}`);
@@ -30431,10 +30576,13 @@ ${details}` : "Route addition completed but unable to verify.";
30431
30576
  }
30432
30577
  async function setRouteDisabled(routeId, disabled, ctx) {
30433
30578
  ctx.info(`Updating route: route_id=${routeId}`);
30434
- const result = await executeMikrotikCommand(`/ip route set ${routeId} disabled=${yesno(disabled)}`, ctx);
30579
+ const id = await resolveRouteId(routeId, ctx);
30580
+ if (!id)
30581
+ return routeNotFound(routeId);
30582
+ const result = await executeMikrotikCommand(`/ip route set ${id} disabled=${yesno(disabled)}`, ctx);
30435
30583
  if (looksLikeError(result))
30436
30584
  return `Failed to update route: ${result}`;
30437
- const details = await executeMikrotikCommand(`/ip route print detail where .id=${routeId}`, ctx);
30585
+ const details = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30438
30586
  return `Route updated successfully:
30439
30587
 
30440
30588
  ${details}`;
@@ -30517,8 +30665,11 @@ ${result}`;
30517
30665
  },
30518
30666
  async handler(a, ctx) {
30519
30667
  ctx.info(`Getting route details: route_id=${a.route_id}`);
30520
- const result = await executeMikrotikCommand(`/ip route print detail where .id=${a.route_id}`, ctx);
30521
- return isEmpty(result) ? `Route with ID '${a.route_id}' not found.` : `ROUTE DETAILS:
30668
+ const id = await resolveRouteId(a.route_id, ctx);
30669
+ if (!id)
30670
+ return routeNotFound(a.route_id);
30671
+ const result = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30672
+ return isEmpty(result) ? routeNotFound(a.route_id) : `ROUTE DETAILS:
30522
30673
 
30523
30674
  ${result}`;
30524
30675
  }
@@ -30547,7 +30698,10 @@ ${result}`;
30547
30698
  },
30548
30699
  async handler(a, ctx) {
30549
30700
  ctx.info(`Updating route: route_id=${a.route_id}`);
30550
- const base = `/ip route set ${a.route_id}`;
30701
+ const id = await resolveRouteId(a.route_id, ctx);
30702
+ if (!id)
30703
+ return routeNotFound(a.route_id);
30704
+ const base = `/ip route set ${id}`;
30551
30705
  const cmd = new Cmd(base);
30552
30706
  if (a.dst_address)
30553
30707
  cmd.set("dst-address", a.dst_address);
@@ -30585,8 +30739,8 @@ ${result}`;
30585
30739
  const result = await executeMikrotikCommand(built, ctx);
30586
30740
  if (looksLikeError(result))
30587
30741
  return `Failed to update route: ${result}`;
30588
- const details = await executeMikrotikCommand(`/ip route print detail where .id=${a.route_id}`, ctx);
30589
- return `Route updated successfully:
30742
+ const details = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30743
+ return `Route ${id} updated successfully:
30590
30744
 
30591
30745
  ${details}`;
30592
30746
  }
@@ -30601,13 +30755,16 @@ ${details}`;
30601
30755
  },
30602
30756
  async handler(a, ctx) {
30603
30757
  ctx.info(`Removing route: route_id=${a.route_id}`);
30604
- const count = await executeMikrotikCommand(`/ip route print count-only where .id=${a.route_id}`, ctx);
30605
- if (count.trim() === "0")
30606
- return `Route with ID '${a.route_id}' not found.`;
30607
- const result = await executeMikrotikCommand(`/ip route remove ${a.route_id}`, ctx);
30758
+ const id = await resolveRouteId(a.route_id, ctx);
30759
+ if (!id)
30760
+ return routeNotFound(a.route_id);
30761
+ const doomed = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30762
+ const result = await executeMikrotikCommand(`/ip route remove ${id}`, ctx);
30608
30763
  if (looksLikeError(result))
30609
30764
  return `Failed to remove route: ${result}`;
30610
- return `Route with ID '${a.route_id}' removed successfully.`;
30765
+ return `Route ${id} removed successfully. Removed entry was:
30766
+
30767
+ ${doomed}`;
30611
30768
  }
30612
30769
  }),
30613
30770
  defineTool({
@@ -32135,14 +32292,22 @@ ${result}`;
32135
32292
  name: "add_ospf_interface_template",
32136
32293
  title: "Add OSPF Interface Template",
32137
32294
  annotations: WRITE,
32138
- description: "Bind interfaces or network prefixes to an OSPF area (`/routing ospf interface-template add`). " + "Match links via `interfaces` (interface or interface-list name) and/or `networks` (prefix, " + "e.g. '10.0.0.0/24'). `type` sets the link model: broadcast (LAN), ptp (point-to-point), " + "ptmp, ptmp-broadcast, nbma, or virtual-link. `passive=true` advertises the subnet without forming OSPF adjacencies " + "(for stub networks). `auth`/`auth_id`/`auth_key` enable per-interface authentication; `hello_interval` " + "e.g. '10s', `dead_interval` e.g. '40s'. " + "Requires the area to already exist (add_ospf_area). " + "Returns the new template id (e.g. '*2') if RouterOS echoes one; use list_ospf_interface_templates to verify.",
32295
+ description: "Bind interfaces or network prefixes to an OSPF area (`/routing ospf interface-template add`). " + "Match links via `interfaces` (interface or interface-list name) and/or `networks` (prefix, " + "e.g. '10.0.0.0/24'). `type` sets the link model: broadcast (LAN), ptp (point-to-point), " + "ptp-unnumbered (point-to-point over an unnumbered link), ptmp, ptmp-broadcast, nbma, or " + "virtual-link. `passive=true` advertises the subnet without forming OSPF adjacencies " + "(for stub networks). `auth`/`auth_id`/`auth_key` enable per-interface authentication; `hello_interval` " + "e.g. '10s', `dead_interval` e.g. '40s'. " + "Requires the area to already exist (add_ospf_area). " + "Returns the new template id (e.g. '*2') if RouterOS echoes one; use list_ospf_interface_templates to verify.",
32139
32296
  inputSchema: {
32140
32297
  area: z107.string().describe("OSPF area name to attach matched interfaces to"),
32141
32298
  interfaces: z107.string().optional().describe("Interface or interface-list name"),
32142
32299
  networks: z107.string().optional().describe('Network prefix(es) to enable OSPF on, e.g. "10.0.0.0/24"'),
32143
32300
  cost: z107.number().int().optional().describe("Output cost / metric"),
32144
32301
  priority: z107.number().int().optional().describe("DR election priority (0 = never DR)"),
32145
- type: z107.enum(["broadcast", "ptp", "ptmp", "ptmp-broadcast", "nbma", "virtual-link"]).optional(),
32302
+ type: z107.enum([
32303
+ "broadcast",
32304
+ "ptp",
32305
+ "ptp-unnumbered",
32306
+ "ptmp",
32307
+ "ptmp-broadcast",
32308
+ "nbma",
32309
+ "virtual-link"
32310
+ ]).optional(),
32146
32311
  passive: z107.boolean().optional(),
32147
32312
  hello_interval: z107.string().optional().describe('e.g. "10s"'),
32148
32313
  dead_interval: z107.string().optional().describe('e.g. "40s"'),
@@ -34988,7 +35153,7 @@ function resolveTargets(targets) {
34988
35153
  const cfg = getConfig();
34989
35154
  const notes = [];
34990
35155
  if (Array.isArray(targets)) {
34991
- const unknown = targets.filter((t) => !(resolveDeviceName(t) in cfg.devices));
35156
+ const unknown = targets.filter((t) => !tryResolveDeviceName(t));
34992
35157
  if (unknown.length > 0) {
34993
35158
  return { devices: [], notes, error: `unknown device(s): ${unknown.join(", ")}` };
34994
35159
  }
@@ -35955,7 +36120,7 @@ var riskByTool = null;
35955
36120
  async function riskIndex() {
35956
36121
  if (riskByTool)
35957
36122
  return riskByTool;
35958
- const { moduleCatalog } = await import("./cli-qqwjbjnv.js");
36123
+ const { moduleCatalog } = await import("./cli-h41j14aw.js");
35959
36124
  const index = new Map;
35960
36125
  for (const mod of moduleCatalog) {
35961
36126
  for (const tool of mod.tools)
@@ -42932,6 +43097,8 @@ ${config}`;
42932
43097
 
42933
43098
  // src/tools/wireguard.ts
42934
43099
  import { z as z138 } from "zod";
43100
+ var resolvePeerId = ruleResolver("/interface wireguard peers");
43101
+ var peerNotFound = (id) => notFoundMessage("WireGuard peer", id, "list_wireguard_peers");
42935
43102
  var wireguardTools = [
42936
43103
  defineTool({
42937
43104
  name: "create_wireguard_interface",
@@ -43056,17 +43223,26 @@ ${details}`;
43056
43223
  name: "remove_wireguard_interface",
43057
43224
  title: "Remove WireGuard Interface",
43058
43225
  annotations: DESTRUCTIVE,
43059
- description: "Permanently deletes a WireGuard tunnel interface (`/interface wireguard remove`) by name." + " Verifies existence first with count-only; removing the interface also removes all its associated peers." + " For removing only a specific peer without touching the interface use remove_wireguard_peer.",
43226
+ description: "Permanently deletes a WireGuard tunnel interface (`/interface wireguard remove`) by name," + " together with every peer bound to it. Verifies existence first with count-only." + " For removing only a specific peer without touching the interface use remove_wireguard_peer." + " Reports how many peers were removed alongside the interface.",
43060
43227
  inputSchema: { name: z138.string() },
43061
43228
  async handler(a, ctx) {
43062
43229
  ctx.info(`Removing WireGuard interface: name=${a.name}`);
43063
43230
  const count = await executeMikrotikCommand(`/interface wireguard print count-only where name="${a.name}"`, ctx);
43064
43231
  if (count.trim() === "0")
43065
43232
  return `WireGuard interface '${a.name}' not found.`;
43233
+ const peerCount = (await executeMikrotikCommand(`/interface wireguard peers print count-only where interface="${a.name}"`, ctx)).trim();
43234
+ if (peerCount !== "0" && !looksLikeError(peerCount)) {
43235
+ const peerResult = await executeMikrotikCommand(`/interface wireguard peers remove [find interface="${a.name}"]`, ctx);
43236
+ if (looksLikeError(peerResult)) {
43237
+ return `Failed to remove the peers bound to '${a.name}': ${peerResult}
43238
+ ` + "The interface was NOT removed \u2014 removing it now would orphan those peers.";
43239
+ }
43240
+ }
43066
43241
  const result = await executeMikrotikCommand(`/interface wireguard remove [find name="${a.name}"]`, ctx);
43067
43242
  if (looksLikeError(result))
43068
43243
  return `Failed to remove WireGuard interface: ${result}`;
43069
- return `WireGuard interface '${a.name}' removed successfully.`;
43244
+ const peers = peerCount === "0" ? "no peers were bound to it" : `${peerCount} peer(s) removed`;
43245
+ return `WireGuard interface '${a.name}' removed successfully (${peers}).`;
43070
43246
  }
43071
43247
  }),
43072
43248
  defineTool({
@@ -43173,8 +43349,11 @@ ${result}`;
43173
43349
  },
43174
43350
  async handler(a, ctx) {
43175
43351
  ctx.info(`Getting WireGuard peer details: peer_id=${a.peer_id}`);
43176
- const result = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${a.peer_id}`, ctx);
43177
- return isEmpty(result) ? `WireGuard peer with ID '${a.peer_id}' not found.` : `WIREGUARD PEER DETAILS:
43352
+ const id = await resolvePeerId(a.peer_id, ctx);
43353
+ if (!id)
43354
+ return peerNotFound(a.peer_id);
43355
+ const result = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${id}`, ctx);
43356
+ return isEmpty(result) ? peerNotFound(a.peer_id) : `WIREGUARD PEER DETAILS:
43178
43357
 
43179
43358
  ${result}`;
43180
43359
  }
@@ -43192,6 +43371,7 @@ ${result}`;
43192
43371
  ` + ' Pass "" for endpoint_address or preshared_key to clear them.',
43193
43372
  inputSchema: {
43194
43373
  peer_id: z138.string().describe('"*N" or "N" from list output e.g. "*2"'),
43374
+ public_key: z138.string().optional().describe("The peer's base64 public key \u2014 re-key a peer in place instead of removing and " + "re-adding it (which would lose the rest of its configuration)."),
43195
43375
  allowed_address: z138.string().optional(),
43196
43376
  endpoint_address: z138.string().optional(),
43197
43377
  endpoint_port: z138.number().int().optional(),
@@ -43210,15 +43390,18 @@ ${result}`;
43210
43390
  },
43211
43391
  async handler(a, ctx) {
43212
43392
  ctx.info(`Updating WireGuard peer: peer_id=${a.peer_id}`);
43213
- const base = `/interface wireguard peers set ${a.peer_id}`;
43214
- const cmd = new Cmd(base).raw(a.allowed_address !== undefined ? `allowed-address=${quoteValue(a.allowed_address)}` : null).raw(a.endpoint_address !== undefined ? a.endpoint_address === "" ? "!endpoint-address" : `endpoint-address=${quoteValue(a.endpoint_address)}` : null).opt("endpoint-port", a.endpoint_port).raw(a.preshared_key !== undefined ? a.preshared_key === "" ? "!preshared-key" : `preshared-key=${quoteValue(a.preshared_key)}` : null).raw(a.persistent_keepalive !== undefined ? `persistent-keepalive=${a.persistent_keepalive}` : null).opt("name", a.name).opt("private-key", a.private_key).bool("responder", a.responder).raw(a.client_address !== undefined ? a.client_address === "" ? "!client-address" : `client-address=${quoteValue(a.client_address)}` : null).raw(a.client_dns !== undefined ? a.client_dns === "" ? "!client-dns" : `client-dns=${quoteValue(a.client_dns)}` : null).raw(a.client_endpoint !== undefined ? a.client_endpoint === "" ? "!client-endpoint" : `client-endpoint=${quoteValue(a.client_endpoint)}` : null).opt("client-keepalive", a.client_keepalive).opt("client-listen-port", a.client_listen_port).raw(a.comment !== undefined ? `comment=${quoteValue(a.comment)}` : null).bool("disabled", a.disabled).build();
43393
+ const id = await resolvePeerId(a.peer_id, ctx);
43394
+ if (!id)
43395
+ return peerNotFound(a.peer_id);
43396
+ const base = `/interface wireguard peers set ${id}`;
43397
+ const cmd = new Cmd(base).raw(a.public_key !== undefined ? `public-key=${quoteValue(a.public_key)}` : null).raw(a.allowed_address !== undefined ? `allowed-address=${quoteValue(a.allowed_address)}` : null).raw(a.endpoint_address !== undefined ? a.endpoint_address === "" ? "!endpoint-address" : `endpoint-address=${quoteValue(a.endpoint_address)}` : null).opt("endpoint-port", a.endpoint_port).raw(a.preshared_key !== undefined ? a.preshared_key === "" ? "!preshared-key" : `preshared-key=${quoteValue(a.preshared_key)}` : null).raw(a.persistent_keepalive !== undefined ? `persistent-keepalive=${a.persistent_keepalive}` : null).opt("name", a.name).opt("private-key", a.private_key).bool("responder", a.responder).raw(a.client_address !== undefined ? a.client_address === "" ? "!client-address" : `client-address=${quoteValue(a.client_address)}` : null).raw(a.client_dns !== undefined ? a.client_dns === "" ? "!client-dns" : `client-dns=${quoteValue(a.client_dns)}` : null).raw(a.client_endpoint !== undefined ? a.client_endpoint === "" ? "!client-endpoint" : `client-endpoint=${quoteValue(a.client_endpoint)}` : null).opt("client-keepalive", a.client_keepalive).opt("client-listen-port", a.client_listen_port).raw(a.comment !== undefined ? `comment=${quoteValue(a.comment)}` : null).bool("disabled", a.disabled).build();
43215
43398
  if (cmd === base)
43216
43399
  return "No updates specified.";
43217
43400
  const result = await executeMikrotikCommand(cmd, ctx);
43218
43401
  if (looksLikeError(result))
43219
43402
  return `Failed to update WireGuard peer: ${result}`;
43220
- const details = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${a.peer_id}`, ctx);
43221
- return `WireGuard peer updated successfully:
43403
+ const details = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${id}`, ctx);
43404
+ return `WireGuard peer ${id} updated successfully:
43222
43405
 
43223
43406
  ${details}`;
43224
43407
  }
@@ -43236,13 +43419,16 @@ ${details}`;
43236
43419
  },
43237
43420
  async handler(a, ctx) {
43238
43421
  ctx.info(`Removing WireGuard peer: peer_id=${a.peer_id}`);
43239
- const count = await executeMikrotikCommand(`/interface wireguard peers print count-only where .id=${a.peer_id}`, ctx);
43240
- if (count.trim() === "0")
43241
- return `WireGuard peer with ID '${a.peer_id}' not found.`;
43242
- const result = await executeMikrotikCommand(`/interface wireguard peers remove ${a.peer_id}`, ctx);
43422
+ const id = await resolvePeerId(a.peer_id, ctx);
43423
+ if (!id)
43424
+ return peerNotFound(a.peer_id);
43425
+ const doomed = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${id}`, ctx);
43426
+ const result = await executeMikrotikCommand(`/interface wireguard peers remove ${id}`, ctx);
43243
43427
  if (looksLikeError(result))
43244
43428
  return `Failed to remove WireGuard peer: ${result}`;
43245
- return `WireGuard peer '${a.peer_id}' removed successfully.`;
43429
+ return `WireGuard peer ${id} removed successfully. Removed entry was:
43430
+
43431
+ ${doomed}`;
43246
43432
  }
43247
43433
  }),
43248
43434
  defineTool({
@@ -43258,10 +43444,13 @@ ${details}`;
43258
43444
  },
43259
43445
  async handler(a, ctx) {
43260
43446
  ctx.info(`Enabling WireGuard peer: peer_id=${a.peer_id}`);
43261
- const result = await executeMikrotikCommand(`/interface wireguard peers enable ${a.peer_id}`, ctx);
43447
+ const id = await resolvePeerId(a.peer_id, ctx);
43448
+ if (!id)
43449
+ return peerNotFound(a.peer_id);
43450
+ const result = await executeMikrotikCommand(`/interface wireguard peers enable ${id}`, ctx);
43262
43451
  if (looksLikeError(result))
43263
43452
  return `Failed to enable WireGuard peer: ${result}`;
43264
- return `WireGuard peer '${a.peer_id}' enabled successfully.`;
43453
+ return `WireGuard peer ${id} enabled successfully.`;
43265
43454
  }
43266
43455
  }),
43267
43456
  defineTool({
@@ -43277,10 +43466,13 @@ ${details}`;
43277
43466
  },
43278
43467
  async handler(a, ctx) {
43279
43468
  ctx.info(`Disabling WireGuard peer: peer_id=${a.peer_id}`);
43280
- const result = await executeMikrotikCommand(`/interface wireguard peers disable ${a.peer_id}`, ctx);
43469
+ const id = await resolvePeerId(a.peer_id, ctx);
43470
+ if (!id)
43471
+ return peerNotFound(a.peer_id);
43472
+ const result = await executeMikrotikCommand(`/interface wireguard peers disable ${id}`, ctx);
43281
43473
  if (looksLikeError(result))
43282
43474
  return `Failed to disable WireGuard peer: ${result}`;
43283
- return `WireGuard peer '${a.peer_id}' disabled successfully.`;
43475
+ return `WireGuard peer ${id} disabled successfully.`;
43284
43476
  }
43285
43477
  }),
43286
43478
  defineTool({