@usex/mikrotik-mcp 5.4.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);
@@ -766,20 +778,36 @@ function deviceLabels() {
766
778
  }
767
779
  return out;
768
780
  }
769
- function resolveDeviceName(name) {
770
- if (name) {
771
- if (name in active.devices && isEnabled(active.devices[name]))
772
- return name;
773
- const byLabel = deviceKeyForLabel(name);
774
- if (byLabel)
775
- return byLabel;
776
- }
781
+ function defaultDeviceKey() {
777
782
  if (active.defaultDevice in active.devices && isEnabled(active.devices[active.defaultDevice])) {
778
783
  return active.defaultDevice;
779
784
  }
780
785
  const firstEnabled = Object.entries(active.devices).find(([, dc]) => isEnabled(dc));
781
786
  return firstEnabled ? firstEnabled[0] : active.defaultDevice;
782
787
  }
788
+ function tryResolveDeviceName(name) {
789
+ if (!name)
790
+ return;
791
+ if (name in active.devices && isEnabled(active.devices[name]))
792
+ return name;
793
+ return deviceKeyForLabel(name);
794
+ }
795
+ function resolveDeviceName(name) {
796
+ if (!name)
797
+ return defaultDeviceKey();
798
+ const resolved = tryResolveDeviceName(name);
799
+ if (resolved)
800
+ return resolved;
801
+ throw new Error(unknownDeviceMessage(name));
802
+ }
803
+ function unknownDeviceMessage(name) {
804
+ const enabled = listDevices().names;
805
+ const disabled = name in active.devices && !isEnabled(active.devices[name]);
806
+ if (disabled) {
807
+ return `Device '${name}' is disabled. Enable it from the dashboard or config file. Enabled devices: ${enabled.join(", ")}`;
808
+ }
809
+ 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.";
810
+ }
783
811
  function deviceTarget(dc) {
784
812
  if (!dc)
785
813
  return "?";
@@ -799,9 +827,6 @@ function resolvedTarget(name) {
799
827
  return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
800
828
  }
801
829
  function getDevice(name) {
802
- if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
803
- throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
804
- }
805
830
  const key = resolveDeviceName(name);
806
831
  const dc = active.devices[key];
807
832
  if (!dc)
@@ -2187,6 +2212,27 @@ function parseDetailRecords(lines) {
2187
2212
  flush();
2188
2213
  return rows;
2189
2214
  }
2215
+ function filterDetailBlocks(text, predicate) {
2216
+ const lines = text.split(/\r?\n/);
2217
+ const firstRecord = lines.findIndex((l) => INDEX_LINE.test(l));
2218
+ if (firstRecord === -1)
2219
+ return text;
2220
+ const preamble = lines.slice(0, firstRecord);
2221
+ const blocks = [];
2222
+ for (const line of lines.slice(firstRecord)) {
2223
+ if (INDEX_LINE.test(line) || blocks.length === 0)
2224
+ blocks.push([]);
2225
+ blocks[blocks.length - 1].push(line);
2226
+ }
2227
+ const kept = blocks.filter((block) => {
2228
+ const [row] = parseDetailRecords(block);
2229
+ return row ? predicate(row) : false;
2230
+ });
2231
+ if (kept.length === 0)
2232
+ return "";
2233
+ return [...preamble, ...kept.flat()].join(`
2234
+ `);
2235
+ }
2190
2236
  function parseColumnarRecords(lines) {
2191
2237
  const headerIdx = lines.findIndex((l) => /\S/.test(l) && !l.includes("=") && /^[\s#]*[#A-Z]/.test(l) && /[A-Z]/.test(l));
2192
2238
  if (headerIdx === -1)
@@ -2557,8 +2603,20 @@ function portConflictError(result, port) {
2557
2603
  if (!/configured elsewhere/i.test(result))
2558
2604
  return;
2559
2605
  const other = result.match(/\*\d+\s*=\s*([A-Za-z][\w-]*)/)?.[1];
2560
- const which = port != null ? `port ${port}` : "that port";
2561
- 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.`;
2606
+ if (port == null) {
2607
+ 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.`;
2608
+ }
2609
+ 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.`;
2610
+ }
2611
+ function interfaceValueError(result, fields) {
2612
+ if (!/input does not match any value of interface/i.test(result))
2613
+ return;
2614
+ const sent = fields.filter((f) => f.value !== undefined && f.value !== "");
2615
+ if (sent.length === 0)
2616
+ return;
2617
+ const which = sent.map((f) => `${f.name}='${f.value ?? ""}'`).join(", ");
2618
+ const listHint = sent.map((f) => `${f.name}_list`).join(" / ");
2619
+ 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.";
2562
2620
  }
2563
2621
  function commandUnsupported(result) {
2564
2622
  const t = result.toLowerCase();
@@ -4317,6 +4375,18 @@ function gatingDecision(requires, multiDevice) {
4317
4375
  }
4318
4376
  return { prefix: `[unavailable on this device: ${unmet}]` };
4319
4377
  }
4378
+ var HTML_ENTITIES = /&(amp|lt|gt|quot|#0?39|apos);/i;
4379
+ function htmlEscapedArgument(args) {
4380
+ for (const [key, value] of Object.entries(args)) {
4381
+ if (typeof value !== "string")
4382
+ continue;
4383
+ const hit = value.match(HTML_ENTITIES);
4384
+ if (!hit)
4385
+ continue;
4386
+ 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.`;
4387
+ }
4388
+ return;
4389
+ }
4320
4390
  function defineTool(def) {
4321
4391
  return {
4322
4392
  name: def.name,
@@ -4350,6 +4420,21 @@ function defineTool(def) {
4350
4420
  const callback = async (args) => {
4351
4421
  const { device, reason, ...rest } = args;
4352
4422
  const deviceName = typeof device === "string" ? device : undefined;
4423
+ if (!def.noDevice && deviceName !== undefined && !tryResolveDeviceName(deviceName)) {
4424
+ const text = `Error: ${unknownDeviceMessage(deviceName)}`;
4425
+ logger.error(text);
4426
+ sendLog?.("error", text);
4427
+ return { content: [{ type: "text", text }], isError: true };
4428
+ }
4429
+ if (risk !== "READ") {
4430
+ const escaped = htmlEscapedArgument(rest);
4431
+ if (escaped) {
4432
+ const text = `Error: ${escaped}`;
4433
+ logger.error(text);
4434
+ sendLog?.("error", text);
4435
+ return { content: [{ type: "text", text }], isError: true };
4436
+ }
4437
+ }
4353
4438
  const policy = getAccessPolicy();
4354
4439
  if (policy.enabled) {
4355
4440
  const resolvedDevice = def.noDevice ? undefined : resolveDeviceName(deviceName);
@@ -8209,7 +8294,12 @@ var complianceAuditTools = [
8209
8294
  results.push({ device: resolved, report, text });
8210
8295
  for (const e of report.evaluatedChecks) {
8211
8296
  if (e.result.status === "fail" || e.result.status === "warn") {
8212
- failTally.set(e.check.id, (failTally.get(e.check.id) ?? 0) + 1);
8297
+ const prev = failTally.get(e.check.id);
8298
+ failTally.set(e.check.id, {
8299
+ count: (prev?.count ?? 0) + 1,
8300
+ label: e.result.label,
8301
+ status: prev?.status === "fail" ? "fail" : e.result.status
8302
+ });
8213
8303
  }
8214
8304
  }
8215
8305
  } catch (err) {
@@ -8255,11 +8345,12 @@ Error: ${err instanceof Error ? err.message : String(err)}`
8255
8345
  lines.push("");
8256
8346
  }
8257
8347
  if (failTally.size > 0) {
8258
- const sorted = [...failTally.entries()].sort((a2, b) => b[1] - a2[1]).slice(0, 10);
8348
+ const sorted = [...failTally.entries()].sort((a2, b) => b[1].count - a2[1].count).slice(0, 10);
8259
8349
  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");
8260
- for (const [id, count] of sorted) {
8350
+ for (const [id, { count, label, status }] of sorted) {
8261
8351
  const check = ALL_CHECKS.find((c) => c.id === id);
8262
- lines.push(` ${String(count).padStart(2)}/${targets.length} devices ${id.padEnd(30)} ${check?.title ?? ""}`);
8352
+ const want = check?.title ? ` (should be: ${check.title})` : "";
8353
+ lines.push(` ${String(count).padStart(2)}/${targets.length} devices ${status.toUpperCase().padEnd(4)} ${id.padEnd(30)} ${label}${want}`);
8263
8354
  }
8264
8355
  lines.push("");
8265
8356
  }
@@ -10785,7 +10876,7 @@ var cache2 = null;
10785
10876
  async function gateway() {
10786
10877
  if (cache2)
10787
10878
  return cache2;
10788
- const { moduleCatalog } = await import("./library-241wsfpw.js");
10879
+ const { moduleCatalog } = await import("./library-v52e871r.js");
10789
10880
  const forIndex = [];
10790
10881
  const byName = new Map;
10791
10882
  for (const mod of moduleCatalog) {
@@ -16289,20 +16380,36 @@ Management access may be partially restricted \u2014 review immediately.`;
16289
16380
  import { z as z35 } from "zod";
16290
16381
 
16291
16382
  // src/tools/_resolve-rule-id.ts
16383
+ var VALID_ID = /^(?:\*[0-9a-fA-F]+|\d+)$/;
16384
+ function isValidItemId(id) {
16385
+ return VALID_ID.test(id.trim());
16386
+ }
16387
+ async function listItemIds(scope, ctx) {
16388
+ const idsRaw = await executeMikrotikCommand(`:foreach i in=[${scope} find] do={:put $i}`, ctx);
16389
+ if (isEmpty(idsRaw))
16390
+ return [];
16391
+ return idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
16392
+ }
16292
16393
  function ruleResolver(scope) {
16293
16394
  return async function resolveRuleId(ruleId, ctx) {
16294
- if (/^\d+$/.test(ruleId)) {
16295
- const idsRaw = await executeMikrotikCommand(`:foreach i in=[${scope} find] do={:put $i}`, ctx);
16296
- if (isEmpty(idsRaw))
16395
+ const id = ruleId.trim();
16396
+ if (!isValidItemId(id))
16397
+ return null;
16398
+ if (/^\d+$/.test(id)) {
16399
+ const ids = await listItemIds(scope, ctx);
16400
+ if (ids.length === 0)
16297
16401
  return null;
16298
- const ids = idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
16299
- const pos = Number.parseInt(ruleId, 10);
16402
+ const pos = Number.parseInt(id, 10);
16300
16403
  return pos >= 0 && pos < ids.length ? ids[pos] : null;
16301
16404
  }
16302
- const count = await executeMikrotikCommand(`${scope} print count-only where .id=${ruleId}`, ctx);
16303
- return count.trim() !== "0" ? ruleId : null;
16405
+ const count = await executeMikrotikCommand(`${scope} print count-only where .id=${id}`, ctx);
16406
+ return count.trim() !== "0" ? id : null;
16304
16407
  };
16305
16408
  }
16409
+ function notFoundMessage(what, id, listTool) {
16410
+ 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").`;
16411
+ return `${what} '${id}' not found.${shape}`;
16412
+ }
16306
16413
 
16307
16414
  // src/tools/firewall-filter.ts
16308
16415
  var isDigits = (s) => /^\d+$/.test(s);
@@ -16395,8 +16502,13 @@ async function updateFilterRule(a, ctx) {
16395
16502
  return `Firewall filter rule '${a.rule_id}' not found.`;
16396
16503
  const cmd = `/ip firewall filter set ${id} ${updates.join(" ")}`;
16397
16504
  const result = await executeMikrotikCommand(cmd, ctx);
16398
- if (looksLikeError(result))
16399
- return `Failed to update firewall filter rule: ${result}`;
16505
+ if (looksLikeError(result)) {
16506
+ const hint = interfaceValueError(result, [
16507
+ { name: "in_interface", value: a.in_interface },
16508
+ { name: "out_interface", value: a.out_interface }
16509
+ ]);
16510
+ return `Failed to update firewall filter rule: ${hint ?? result}`;
16511
+ }
16400
16512
  const details = await executeMikrotikCommand(`/ip firewall filter print detail where .id=${id}`, ctx);
16401
16513
  return `Firewall filter rule updated successfully:
16402
16514
 
@@ -16485,7 +16597,10 @@ var firewallFilterTools = [
16485
16597
  const result = await executeMikrotikCommand(cmd, ctx);
16486
16598
  const trimmed = result.trim();
16487
16599
  if (looksLikeError(trimmed)) {
16488
- const hint = placeBeforeError(trimmed, a.place_before);
16600
+ const hint = placeBeforeError(trimmed, a.place_before) ?? interfaceValueError(trimmed, [
16601
+ { name: "in_interface", value: a.in_interface },
16602
+ { name: "out_interface", value: a.out_interface }
16603
+ ]);
16489
16604
  return `Failed to create firewall filter rule: ${hint ?? trimmed}`;
16490
16605
  }
16491
16606
  const createdId = extractCreatedId(trimmed);
@@ -16533,18 +16648,31 @@ ${details}`;
16533
16648
  filters.push(`src-address~"${a.src_address_filter}"`);
16534
16649
  if (a.dst_address_filter)
16535
16650
  filters.push(`dst-address~"${a.dst_address_filter}"`);
16536
- if (a.protocol_filter)
16537
- filters.push(`protocol=${a.protocol_filter}`);
16538
- if (a.interface_filter) {
16539
- filters.push(`(in-interface~"${a.interface_filter}" or out-interface~"${a.interface_filter}")`);
16540
- }
16541
16651
  if (a.disabled_only)
16542
16652
  filters.push("disabled=yes");
16543
16653
  if (a.invalid_only)
16544
16654
  filters.push("invalid=yes");
16545
16655
  if (a.dynamic_only)
16546
16656
  filters.push("dynamic=yes");
16547
- const result = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
16657
+ const raw = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
16658
+ const protocolWanted = a.protocol_filter?.toLowerCase();
16659
+ const interfaceWanted = a.interface_filter?.toLowerCase();
16660
+ const result = protocolWanted || interfaceWanted ? filterDetailBlocks(raw, (row) => {
16661
+ if (protocolWanted && (row.protocol ?? "").toLowerCase() !== protocolWanted) {
16662
+ return false;
16663
+ }
16664
+ if (interfaceWanted) {
16665
+ const hit = [
16666
+ row["in-interface"],
16667
+ row["out-interface"],
16668
+ row["in-interface-list"],
16669
+ row["out-interface-list"]
16670
+ ].some((v) => (v ?? "").toLowerCase().includes(interfaceWanted));
16671
+ if (!hit)
16672
+ return false;
16673
+ }
16674
+ return true;
16675
+ }) : raw;
16548
16676
  return isEmpty(result) ? "No firewall filter rules found matching the criteria." : `FIREWALL FILTER RULES:
16549
16677
 
16550
16678
  ${result}`;
@@ -16554,7 +16682,7 @@ ${result}`;
16554
16682
  name: "get_filter_rule",
16555
16683
  title: "Get Firewall Filter Rule",
16556
16684
  annotations: READ,
16557
- 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.",
16685
+ 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`.",
16558
16686
  inputSchema: {
16559
16687
  rule_id: z35.string().describe('Rule .id e.g. "*1F", or bare position number e.g. "3"')
16560
16688
  },
@@ -16662,10 +16790,23 @@ ${result}`;
16662
16790
  const id = await resolveFilterRuleId(a.rule_id, ctx);
16663
16791
  if (!id)
16664
16792
  return `Firewall filter rule '${a.rule_id}' not found.`;
16793
+ const before = await listItemIds("/ip firewall filter", ctx);
16665
16794
  const result = await executeMikrotikCommand(`/ip firewall filter move ${id} destination=${a.destination}`, ctx);
16666
16795
  if (looksLikeError(result))
16667
16796
  return `Failed to move firewall filter rule: ${result}`;
16668
- return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${a.destination}.`;
16797
+ const after = await listItemIds("/ip firewall filter", ctx);
16798
+ const from = before.indexOf(id);
16799
+ const to = after.indexOf(id);
16800
+ if (to === -1) {
16801
+ return `Firewall filter rule ${id} moved, but it could not be located afterwards \u2014 verify with list_filter_rules.`;
16802
+ }
16803
+ if (to === a.destination) {
16804
+ return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${to}.`;
16805
+ }
16806
+ if (to === from) {
16807
+ 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.";
16808
+ }
16809
+ 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).";
16669
16810
  }
16670
16811
  }),
16671
16812
  defineTool({
@@ -18405,6 +18546,7 @@ ${result}`;
18405
18546
 
18406
18547
  // src/tools/ip-service.ts
18407
18548
  import { z as z43 } from "zod";
18549
+ var staticService = (name) => `[find name="${name}" and !dynamic]`;
18408
18550
  var ipServiceTools = [
18409
18551
  defineTool({
18410
18552
  name: "list_ip_services",
@@ -18450,7 +18592,7 @@ ${result}`;
18450
18592
  },
18451
18593
  async handler(a, ctx) {
18452
18594
  ctx.info(`Updating IP service: name=${a.name}`);
18453
- 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();
18595
+ 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();
18454
18596
  if (!cmd.includes("=", cmd.indexOf("]")))
18455
18597
  return "No updates specified.";
18456
18598
  const result = await executeMikrotikCommand(cmd, ctx);
@@ -18474,7 +18616,7 @@ ${details}`;
18474
18616
  },
18475
18617
  async handler(a, ctx) {
18476
18618
  ctx.info(`Enabling IP service: name=${a.name}`);
18477
- const result = await executeMikrotikCommand(`/ip service enable [find name="${a.name}"]`, ctx);
18619
+ const result = await executeMikrotikCommand(`/ip service enable ${staticService(a.name)}`, ctx);
18478
18620
  if (looksLikeError(result))
18479
18621
  return `Failed to enable IP service: ${result}`;
18480
18622
  return `IP service '${a.name}' enabled successfully.`;
@@ -18490,7 +18632,7 @@ ${details}`;
18490
18632
  },
18491
18633
  async handler(a, ctx) {
18492
18634
  ctx.info(`Disabling IP service: name=${a.name}`);
18493
- const result = await executeMikrotikCommand(`/ip service disable [find name="${a.name}"]`, ctx);
18635
+ const result = await executeMikrotikCommand(`/ip service disable ${staticService(a.name)}`, ctx);
18494
18636
  if (looksLikeError(result))
18495
18637
  return `Failed to disable IP service: ${result}`;
18496
18638
  return `IP service '${a.name}' disabled successfully.`;
@@ -24987,7 +25129,7 @@ var speedTestTools = [
24987
25129
  },
24988
25130
  async handler(a, ctx) {
24989
25131
  ctx.info(`Speed test to ${a.address} (test-duration=${a.duration}s)`);
24990
- 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();
25132
+ 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();
24991
25133
  const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.duration * 1000 + 15000 }));
24992
25134
  if (looksLikeError(result))
24993
25135
  return `Failed to run speed test to ${a.address}: ${result}`;
@@ -29643,10 +29785,11 @@ async function collectRoutes(ctx) {
29643
29785
  dst: r["dst-address"] ?? "",
29644
29786
  gateway: r.gateway ?? "",
29645
29787
  distance: num(r.distance),
29646
- active: (r.flags ?? "").includes("A") || r.active === "true",
29647
- dynamic: (r.flags ?? "").includes("D") || r.dynamic === "true"
29788
+ active: (r.flags ?? "").includes("A") || isYes(r.active),
29789
+ dynamic: (r.flags ?? "").includes("D") || isYes(r.dynamic)
29648
29790
  }));
29649
- const hasDefault = routes.some((r) => r.dst === "0.0.0.0/0" && r.active);
29791
+ const isDefaultDst = (dst) => dst === "0.0.0.0/0" || dst === "0.0.0.0/0*";
29792
+ const hasDefault = routes.some((r) => isDefaultDst(r.dst) && r.active);
29650
29793
  return { routes, count: routes.length, hasDefault };
29651
29794
  }
29652
29795
  async function collectOspfNeighbors(ctx) {
@@ -30157,6 +30300,8 @@ var multiwanTools = [
30157
30300
 
30158
30301
  // src/tools/routes.ts
30159
30302
  import { z as z99 } from "zod";
30303
+ var resolveRouteId = ruleResolver("/ip route");
30304
+ var routeNotFound = (id) => notFoundMessage("Route", id, "list_routes");
30160
30305
  var RouteType2 = z99.enum(["unicast", "blackhole"]);
30161
30306
  async function addRoute(a, ctx) {
30162
30307
  ctx.info(`Adding route: dst=${a.dst_address}, gateway=${a.gateway}`);
@@ -30179,10 +30324,13 @@ ${details}` : "Route addition completed but unable to verify.";
30179
30324
  }
30180
30325
  async function setRouteDisabled(routeId, disabled, ctx) {
30181
30326
  ctx.info(`Updating route: route_id=${routeId}`);
30182
- const result = await executeMikrotikCommand(`/ip route set ${routeId} disabled=${yesno(disabled)}`, ctx);
30327
+ const id = await resolveRouteId(routeId, ctx);
30328
+ if (!id)
30329
+ return routeNotFound(routeId);
30330
+ const result = await executeMikrotikCommand(`/ip route set ${id} disabled=${yesno(disabled)}`, ctx);
30183
30331
  if (looksLikeError(result))
30184
30332
  return `Failed to update route: ${result}`;
30185
- const details = await executeMikrotikCommand(`/ip route print detail where .id=${routeId}`, ctx);
30333
+ const details = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30186
30334
  return `Route updated successfully:
30187
30335
 
30188
30336
  ${details}`;
@@ -30265,8 +30413,11 @@ ${result}`;
30265
30413
  },
30266
30414
  async handler(a, ctx) {
30267
30415
  ctx.info(`Getting route details: route_id=${a.route_id}`);
30268
- const result = await executeMikrotikCommand(`/ip route print detail where .id=${a.route_id}`, ctx);
30269
- return isEmpty(result) ? `Route with ID '${a.route_id}' not found.` : `ROUTE DETAILS:
30416
+ const id = await resolveRouteId(a.route_id, ctx);
30417
+ if (!id)
30418
+ return routeNotFound(a.route_id);
30419
+ const result = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30420
+ return isEmpty(result) ? routeNotFound(a.route_id) : `ROUTE DETAILS:
30270
30421
 
30271
30422
  ${result}`;
30272
30423
  }
@@ -30295,7 +30446,10 @@ ${result}`;
30295
30446
  },
30296
30447
  async handler(a, ctx) {
30297
30448
  ctx.info(`Updating route: route_id=${a.route_id}`);
30298
- const base = `/ip route set ${a.route_id}`;
30449
+ const id = await resolveRouteId(a.route_id, ctx);
30450
+ if (!id)
30451
+ return routeNotFound(a.route_id);
30452
+ const base = `/ip route set ${id}`;
30299
30453
  const cmd = new Cmd(base);
30300
30454
  if (a.dst_address)
30301
30455
  cmd.set("dst-address", a.dst_address);
@@ -30333,8 +30487,8 @@ ${result}`;
30333
30487
  const result = await executeMikrotikCommand(built, ctx);
30334
30488
  if (looksLikeError(result))
30335
30489
  return `Failed to update route: ${result}`;
30336
- const details = await executeMikrotikCommand(`/ip route print detail where .id=${a.route_id}`, ctx);
30337
- return `Route updated successfully:
30490
+ const details = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30491
+ return `Route ${id} updated successfully:
30338
30492
 
30339
30493
  ${details}`;
30340
30494
  }
@@ -30349,13 +30503,16 @@ ${details}`;
30349
30503
  },
30350
30504
  async handler(a, ctx) {
30351
30505
  ctx.info(`Removing route: route_id=${a.route_id}`);
30352
- const count = await executeMikrotikCommand(`/ip route print count-only where .id=${a.route_id}`, ctx);
30353
- if (count.trim() === "0")
30354
- return `Route with ID '${a.route_id}' not found.`;
30355
- const result = await executeMikrotikCommand(`/ip route remove ${a.route_id}`, ctx);
30506
+ const id = await resolveRouteId(a.route_id, ctx);
30507
+ if (!id)
30508
+ return routeNotFound(a.route_id);
30509
+ const doomed = await executeMikrotikCommand(`/ip route print detail where .id=${id}`, ctx);
30510
+ const result = await executeMikrotikCommand(`/ip route remove ${id}`, ctx);
30356
30511
  if (looksLikeError(result))
30357
30512
  return `Failed to remove route: ${result}`;
30358
- return `Route with ID '${a.route_id}' removed successfully.`;
30513
+ return `Route ${id} removed successfully. Removed entry was:
30514
+
30515
+ ${doomed}`;
30359
30516
  }
30360
30517
  }),
30361
30518
  defineTool({
@@ -31883,14 +32040,22 @@ ${result}`;
31883
32040
  name: "add_ospf_interface_template",
31884
32041
  title: "Add OSPF Interface Template",
31885
32042
  annotations: WRITE,
31886
- 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.",
32043
+ 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.",
31887
32044
  inputSchema: {
31888
32045
  area: z107.string().describe("OSPF area name to attach matched interfaces to"),
31889
32046
  interfaces: z107.string().optional().describe("Interface or interface-list name"),
31890
32047
  networks: z107.string().optional().describe('Network prefix(es) to enable OSPF on, e.g. "10.0.0.0/24"'),
31891
32048
  cost: z107.number().int().optional().describe("Output cost / metric"),
31892
32049
  priority: z107.number().int().optional().describe("DR election priority (0 = never DR)"),
31893
- type: z107.enum(["broadcast", "ptp", "ptmp", "ptmp-broadcast", "nbma", "virtual-link"]).optional(),
32050
+ type: z107.enum([
32051
+ "broadcast",
32052
+ "ptp",
32053
+ "ptp-unnumbered",
32054
+ "ptmp",
32055
+ "ptmp-broadcast",
32056
+ "nbma",
32057
+ "virtual-link"
32058
+ ]).optional(),
31894
32059
  passive: z107.boolean().optional(),
31895
32060
  hello_interval: z107.string().optional().describe('e.g. "10s"'),
31896
32061
  dead_interval: z107.string().optional().describe('e.g. "40s"'),
@@ -34528,7 +34693,7 @@ function resolveTargets(targets) {
34528
34693
  const cfg = getConfig();
34529
34694
  const notes = [];
34530
34695
  if (Array.isArray(targets)) {
34531
- const unknown = targets.filter((t) => !(resolveDeviceName(t) in cfg.devices));
34696
+ const unknown = targets.filter((t) => !tryResolveDeviceName(t));
34532
34697
  if (unknown.length > 0) {
34533
34698
  return { devices: [], notes, error: `unknown device(s): ${unknown.join(", ")}` };
34534
34699
  }
@@ -35455,7 +35620,7 @@ var riskByTool = null;
35455
35620
  async function riskIndex() {
35456
35621
  if (riskByTool)
35457
35622
  return riskByTool;
35458
- const { moduleCatalog } = await import("./library-241wsfpw.js");
35623
+ const { moduleCatalog } = await import("./library-v52e871r.js");
35459
35624
  const index = new Map;
35460
35625
  for (const mod of moduleCatalog) {
35461
35626
  for (const tool of mod.tools)
@@ -42307,6 +42472,8 @@ ${config}`;
42307
42472
 
42308
42473
  // src/tools/wireguard.ts
42309
42474
  import { z as z138 } from "zod";
42475
+ var resolvePeerId = ruleResolver("/interface wireguard peers");
42476
+ var peerNotFound = (id) => notFoundMessage("WireGuard peer", id, "list_wireguard_peers");
42310
42477
  var wireguardTools = [
42311
42478
  defineTool({
42312
42479
  name: "create_wireguard_interface",
@@ -42431,17 +42598,26 @@ ${details}`;
42431
42598
  name: "remove_wireguard_interface",
42432
42599
  title: "Remove WireGuard Interface",
42433
42600
  annotations: DESTRUCTIVE,
42434
- 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.",
42601
+ 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.",
42435
42602
  inputSchema: { name: z138.string() },
42436
42603
  async handler(a, ctx) {
42437
42604
  ctx.info(`Removing WireGuard interface: name=${a.name}`);
42438
42605
  const count = await executeMikrotikCommand(`/interface wireguard print count-only where name="${a.name}"`, ctx);
42439
42606
  if (count.trim() === "0")
42440
42607
  return `WireGuard interface '${a.name}' not found.`;
42608
+ const peerCount = (await executeMikrotikCommand(`/interface wireguard peers print count-only where interface="${a.name}"`, ctx)).trim();
42609
+ if (peerCount !== "0" && !looksLikeError(peerCount)) {
42610
+ const peerResult = await executeMikrotikCommand(`/interface wireguard peers remove [find interface="${a.name}"]`, ctx);
42611
+ if (looksLikeError(peerResult)) {
42612
+ return `Failed to remove the peers bound to '${a.name}': ${peerResult}
42613
+ ` + "The interface was NOT removed \u2014 removing it now would orphan those peers.";
42614
+ }
42615
+ }
42441
42616
  const result = await executeMikrotikCommand(`/interface wireguard remove [find name="${a.name}"]`, ctx);
42442
42617
  if (looksLikeError(result))
42443
42618
  return `Failed to remove WireGuard interface: ${result}`;
42444
- return `WireGuard interface '${a.name}' removed successfully.`;
42619
+ const peers = peerCount === "0" ? "no peers were bound to it" : `${peerCount} peer(s) removed`;
42620
+ return `WireGuard interface '${a.name}' removed successfully (${peers}).`;
42445
42621
  }
42446
42622
  }),
42447
42623
  defineTool({
@@ -42548,8 +42724,11 @@ ${result}`;
42548
42724
  },
42549
42725
  async handler(a, ctx) {
42550
42726
  ctx.info(`Getting WireGuard peer details: peer_id=${a.peer_id}`);
42551
- const result = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${a.peer_id}`, ctx);
42552
- return isEmpty(result) ? `WireGuard peer with ID '${a.peer_id}' not found.` : `WIREGUARD PEER DETAILS:
42727
+ const id = await resolvePeerId(a.peer_id, ctx);
42728
+ if (!id)
42729
+ return peerNotFound(a.peer_id);
42730
+ const result = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${id}`, ctx);
42731
+ return isEmpty(result) ? peerNotFound(a.peer_id) : `WIREGUARD PEER DETAILS:
42553
42732
 
42554
42733
  ${result}`;
42555
42734
  }
@@ -42567,6 +42746,7 @@ ${result}`;
42567
42746
  ` + ' Pass "" for endpoint_address or preshared_key to clear them.',
42568
42747
  inputSchema: {
42569
42748
  peer_id: z138.string().describe('"*N" or "N" from list output e.g. "*2"'),
42749
+ 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)."),
42570
42750
  allowed_address: z138.string().optional(),
42571
42751
  endpoint_address: z138.string().optional(),
42572
42752
  endpoint_port: z138.number().int().optional(),
@@ -42585,15 +42765,18 @@ ${result}`;
42585
42765
  },
42586
42766
  async handler(a, ctx) {
42587
42767
  ctx.info(`Updating WireGuard peer: peer_id=${a.peer_id}`);
42588
- const base = `/interface wireguard peers set ${a.peer_id}`;
42589
- 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();
42768
+ const id = await resolvePeerId(a.peer_id, ctx);
42769
+ if (!id)
42770
+ return peerNotFound(a.peer_id);
42771
+ const base = `/interface wireguard peers set ${id}`;
42772
+ 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();
42590
42773
  if (cmd === base)
42591
42774
  return "No updates specified.";
42592
42775
  const result = await executeMikrotikCommand(cmd, ctx);
42593
42776
  if (looksLikeError(result))
42594
42777
  return `Failed to update WireGuard peer: ${result}`;
42595
- const details = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${a.peer_id}`, ctx);
42596
- return `WireGuard peer updated successfully:
42778
+ const details = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${id}`, ctx);
42779
+ return `WireGuard peer ${id} updated successfully:
42597
42780
 
42598
42781
  ${details}`;
42599
42782
  }
@@ -42611,13 +42794,16 @@ ${details}`;
42611
42794
  },
42612
42795
  async handler(a, ctx) {
42613
42796
  ctx.info(`Removing WireGuard peer: peer_id=${a.peer_id}`);
42614
- const count = await executeMikrotikCommand(`/interface wireguard peers print count-only where .id=${a.peer_id}`, ctx);
42615
- if (count.trim() === "0")
42616
- return `WireGuard peer with ID '${a.peer_id}' not found.`;
42617
- const result = await executeMikrotikCommand(`/interface wireguard peers remove ${a.peer_id}`, ctx);
42797
+ const id = await resolvePeerId(a.peer_id, ctx);
42798
+ if (!id)
42799
+ return peerNotFound(a.peer_id);
42800
+ const doomed = await executeMikrotikCommand(`/interface wireguard peers print detail where .id=${id}`, ctx);
42801
+ const result = await executeMikrotikCommand(`/interface wireguard peers remove ${id}`, ctx);
42618
42802
  if (looksLikeError(result))
42619
42803
  return `Failed to remove WireGuard peer: ${result}`;
42620
- return `WireGuard peer '${a.peer_id}' removed successfully.`;
42804
+ return `WireGuard peer ${id} removed successfully. Removed entry was:
42805
+
42806
+ ${doomed}`;
42621
42807
  }
42622
42808
  }),
42623
42809
  defineTool({
@@ -42633,10 +42819,13 @@ ${details}`;
42633
42819
  },
42634
42820
  async handler(a, ctx) {
42635
42821
  ctx.info(`Enabling WireGuard peer: peer_id=${a.peer_id}`);
42636
- const result = await executeMikrotikCommand(`/interface wireguard peers enable ${a.peer_id}`, ctx);
42822
+ const id = await resolvePeerId(a.peer_id, ctx);
42823
+ if (!id)
42824
+ return peerNotFound(a.peer_id);
42825
+ const result = await executeMikrotikCommand(`/interface wireguard peers enable ${id}`, ctx);
42637
42826
  if (looksLikeError(result))
42638
42827
  return `Failed to enable WireGuard peer: ${result}`;
42639
- return `WireGuard peer '${a.peer_id}' enabled successfully.`;
42828
+ return `WireGuard peer ${id} enabled successfully.`;
42640
42829
  }
42641
42830
  }),
42642
42831
  defineTool({
@@ -42652,10 +42841,13 @@ ${details}`;
42652
42841
  },
42653
42842
  async handler(a, ctx) {
42654
42843
  ctx.info(`Disabling WireGuard peer: peer_id=${a.peer_id}`);
42655
- const result = await executeMikrotikCommand(`/interface wireguard peers disable ${a.peer_id}`, ctx);
42844
+ const id = await resolvePeerId(a.peer_id, ctx);
42845
+ if (!id)
42846
+ return peerNotFound(a.peer_id);
42847
+ const result = await executeMikrotikCommand(`/interface wireguard peers disable ${id}`, ctx);
42656
42848
  if (looksLikeError(result))
42657
42849
  return `Failed to disable WireGuard peer: ${result}`;
42658
- return `WireGuard peer '${a.peer_id}' disabled successfully.`;
42850
+ return `WireGuard peer ${id} disabled successfully.`;
42659
42851
  }
42660
42852
  }),
42661
42853
  defineTool({