@usex/mikrotik-mcp 3.54.0 → 3.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +38 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/shared/{cli-xt3qvpz0.js → cli-0ctwspf1.js} +1 -1
- package/dist/shared/{cli-njttbdf8.js → cli-thhk9f7n.js} +218 -76
- package/dist/shared/{library-84y0nvhr.js → library-jve65pzw.js} +1 -1
- package/dist/shared/{library-hk90ad18.js → library-ymtf9zyk.js} +218 -76
- package/dist/ui/observability.html +13 -13
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -96,7 +96,7 @@ import {
|
|
|
96
96
|
toggleAaaEntity,
|
|
97
97
|
updateAaaEntity,
|
|
98
98
|
writeBackup
|
|
99
|
-
} from "./shared/cli-
|
|
99
|
+
} from "./shared/cli-thhk9f7n.js";
|
|
100
100
|
|
|
101
101
|
// src/cli.ts
|
|
102
102
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -1559,6 +1559,7 @@ function devicesPayload(store) {
|
|
|
1559
1559
|
authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
1560
1560
|
isDefault: name === cfg.defaultDevice,
|
|
1561
1561
|
description: dc.description,
|
|
1562
|
+
disabled: !!dc.disabled,
|
|
1562
1563
|
jumpVia: dc.jumpVia,
|
|
1563
1564
|
jumpHost: dc.jumpHost ? { host: dc.jumpHost.host, port: dc.jumpHost.port } : undefined,
|
|
1564
1565
|
status: getDeviceStatus(name),
|
|
@@ -2373,6 +2374,42 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2373
2374
|
if (url.pathname === "/api/devices") {
|
|
2374
2375
|
return json3(devicesPayload(db));
|
|
2375
2376
|
}
|
|
2377
|
+
if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
|
|
2378
|
+
const b = await readJson(req);
|
|
2379
|
+
if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
|
|
2380
|
+
return json3({ error: "device (string) and disabled (boolean) are required" }, 400);
|
|
2381
|
+
}
|
|
2382
|
+
const cfg2 = getConfig();
|
|
2383
|
+
if (!(b.device in cfg2.devices))
|
|
2384
|
+
return json3({ error: `unknown device: ${b.device}` }, 404);
|
|
2385
|
+
const dc = cfg2.devices[b.device];
|
|
2386
|
+
const next = {
|
|
2387
|
+
...cfg2,
|
|
2388
|
+
devices: {
|
|
2389
|
+
...cfg2.devices,
|
|
2390
|
+
[b.device]: { ...dc, disabled: b.disabled || undefined }
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
setConfig(next);
|
|
2394
|
+
let persisted = true;
|
|
2395
|
+
let warning;
|
|
2396
|
+
try {
|
|
2397
|
+
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
2398
|
+
} catch (e) {
|
|
2399
|
+
persisted = false;
|
|
2400
|
+
warning = `applied live but not saved to disk: ${e instanceof Error ? e.message : String(e)}`;
|
|
2401
|
+
}
|
|
2402
|
+
if (persisted) {
|
|
2403
|
+
recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
|
|
2404
|
+
}
|
|
2405
|
+
return json3({
|
|
2406
|
+
ok: true,
|
|
2407
|
+
persisted,
|
|
2408
|
+
requiresReconnect: true,
|
|
2409
|
+
warning,
|
|
2410
|
+
...devicesPayload(db)
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2376
2413
|
if (url.pathname === "/api/topology") {
|
|
2377
2414
|
return json3(topologyPayload());
|
|
2378
2415
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ interface UiLink {
|
|
|
47
47
|
}
|
|
48
48
|
declare function setConfig(cfg: MikrotikConfig): void;
|
|
49
49
|
declare function getConfig(): MikrotikConfig;
|
|
50
|
-
/** Names of every configured device, plus which one is the default. */
|
|
50
|
+
/** Names of every ENABLED configured device, plus which one is the default. */
|
|
51
51
|
declare function listDevices(): {
|
|
52
52
|
names: string[];
|
|
53
53
|
default: string;
|
|
@@ -55,6 +55,7 @@ declare function listDevices(): {
|
|
|
55
55
|
/**
|
|
56
56
|
* Resolve a (possibly undefined) device name to a concrete, existing config key.
|
|
57
57
|
* Accepts a config key OR a device's free-text label; falls back to the default.
|
|
58
|
+
* Only resolves to enabled devices.
|
|
58
59
|
*
|
|
59
60
|
* Matching is EXACT (key first, then label) — never fuzzy/substring — so a name
|
|
60
61
|
* like "Ali Home" can never collapse onto a different device such as "home".
|
|
@@ -71,7 +72,7 @@ interface DeviceDirectoryEntry {
|
|
|
71
72
|
/**
|
|
72
73
|
* Return the connection config for a device by key or label (or the default when
|
|
73
74
|
* `name` is undefined). Throws if an explicit name matches neither a key nor a
|
|
74
|
-
* label.
|
|
75
|
+
* label, or if the device is disabled.
|
|
75
76
|
*/
|
|
76
77
|
declare function getDevice(name?: string): DeviceConfig;
|
|
77
78
|
/** Options threaded into every tool registration. */
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
resolveDeviceName,
|
|
23
23
|
selectToolModules,
|
|
24
24
|
setConfig
|
|
25
|
-
} from "./shared/library-
|
|
25
|
+
} from "./shared/library-ymtf9zyk.js";
|
|
26
26
|
// src/server.ts
|
|
27
27
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
28
28
|
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -337,7 +337,8 @@ var DeviceConfigSchema = z.object({
|
|
|
337
337
|
sourceMac: z.string().optional(),
|
|
338
338
|
macHost: z.string().optional(),
|
|
339
339
|
macPort: z.coerce.number().int().positive().optional(),
|
|
340
|
-
description: z.string().optional()
|
|
340
|
+
description: z.string().optional(),
|
|
341
|
+
disabled: z.boolean().optional()
|
|
341
342
|
});
|
|
342
343
|
var S3ConfigSchema = z.object({
|
|
343
344
|
accessKeyId: z.string().optional(),
|
|
@@ -584,13 +585,17 @@ function setConfig(cfg) {
|
|
|
584
585
|
function getConfig() {
|
|
585
586
|
return active;
|
|
586
587
|
}
|
|
588
|
+
function isEnabled(dc) {
|
|
589
|
+
return !dc.disabled;
|
|
590
|
+
}
|
|
587
591
|
function listDevices() {
|
|
588
|
-
|
|
592
|
+
const names = Object.entries(active.devices).filter(([, dc]) => isEnabled(dc)).map(([k]) => k);
|
|
593
|
+
return { names, default: active.defaultDevice };
|
|
589
594
|
}
|
|
590
595
|
function deviceKeyForLabel(name) {
|
|
591
596
|
const target = name.trim().toLowerCase();
|
|
592
597
|
for (const [key, dc] of Object.entries(active.devices)) {
|
|
593
|
-
if (dc.description && dc.description.trim().toLowerCase() === target)
|
|
598
|
+
if (isEnabled(dc) && dc.description && dc.description.trim().toLowerCase() === target)
|
|
594
599
|
return key;
|
|
595
600
|
}
|
|
596
601
|
return;
|
|
@@ -599,6 +604,8 @@ function deviceLabels() {
|
|
|
599
604
|
const seen = new Set;
|
|
600
605
|
const out = [];
|
|
601
606
|
for (const [key, dc] of Object.entries(active.devices)) {
|
|
607
|
+
if (!isEnabled(dc))
|
|
608
|
+
continue;
|
|
602
609
|
const label = dc.description?.trim();
|
|
603
610
|
if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
|
|
604
611
|
seen.add(label);
|
|
@@ -609,13 +616,17 @@ function deviceLabels() {
|
|
|
609
616
|
}
|
|
610
617
|
function resolveDeviceName(name) {
|
|
611
618
|
if (name) {
|
|
612
|
-
if (name in active.devices)
|
|
619
|
+
if (name in active.devices && isEnabled(active.devices[name]))
|
|
613
620
|
return name;
|
|
614
621
|
const byLabel = deviceKeyForLabel(name);
|
|
615
622
|
if (byLabel)
|
|
616
623
|
return byLabel;
|
|
617
624
|
}
|
|
618
|
-
|
|
625
|
+
if (active.defaultDevice in active.devices && isEnabled(active.devices[active.defaultDevice])) {
|
|
626
|
+
return active.defaultDevice;
|
|
627
|
+
}
|
|
628
|
+
const firstEnabled = Object.entries(active.devices).find(([, dc]) => isEnabled(dc));
|
|
629
|
+
return firstEnabled ? firstEnabled[0] : active.defaultDevice;
|
|
619
630
|
}
|
|
620
631
|
function deviceTarget(dc) {
|
|
621
632
|
if (!dc)
|
|
@@ -623,7 +634,7 @@ function deviceTarget(dc) {
|
|
|
623
634
|
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
624
635
|
}
|
|
625
636
|
function deviceDirectory() {
|
|
626
|
-
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
637
|
+
return Object.entries(active.devices).filter(([, dc]) => isEnabled(dc)).map(([key, dc]) => ({
|
|
627
638
|
key,
|
|
628
639
|
label: dc.description?.trim() || undefined,
|
|
629
640
|
target: deviceTarget(dc),
|
|
@@ -643,6 +654,9 @@ function getDevice(name) {
|
|
|
643
654
|
const dc = active.devices[key];
|
|
644
655
|
if (!dc)
|
|
645
656
|
throw new Error(`No device configuration available for '${key}'.`);
|
|
657
|
+
if (dc.disabled) {
|
|
658
|
+
throw new Error(`Device '${key}' is disabled. Enable it from the dashboard or config file.`);
|
|
659
|
+
}
|
|
646
660
|
return dc;
|
|
647
661
|
}
|
|
648
662
|
|
|
@@ -1762,7 +1776,7 @@ function parseKvTokens(chunk) {
|
|
|
1762
1776
|
return out;
|
|
1763
1777
|
}
|
|
1764
1778
|
var INDEX_LINE = /^\s*(\d+)\s+(.*)$/;
|
|
1765
|
-
var LEADING_FLAGS = /^([A-Z]
|
|
1779
|
+
var LEADING_FLAGS = /^([A-Z][A-Za-z]*)(?=\s|$)/;
|
|
1766
1780
|
function unionColumns(rows) {
|
|
1767
1781
|
const seen = new Set;
|
|
1768
1782
|
const cols = [];
|
|
@@ -8313,7 +8327,7 @@ var cache = null;
|
|
|
8313
8327
|
async function gateway() {
|
|
8314
8328
|
if (cache)
|
|
8315
8329
|
return cache;
|
|
8316
|
-
const { moduleCatalog } = await import("./cli-
|
|
8330
|
+
const { moduleCatalog } = await import("./cli-0ctwspf1.js");
|
|
8317
8331
|
const forIndex = [];
|
|
8318
8332
|
const byName = new Map;
|
|
8319
8333
|
for (const mod of moduleCatalog) {
|
|
@@ -9338,10 +9352,44 @@ function covers(key, aVal, bVal) {
|
|
|
9338
9352
|
return cidrContains(aVal, bVal);
|
|
9339
9353
|
return false;
|
|
9340
9354
|
}
|
|
9355
|
+
var INTERFACE_LIST_PAIRS = [
|
|
9356
|
+
["in-interface-list", "in-interface"],
|
|
9357
|
+
["out-interface-list", "out-interface"]
|
|
9358
|
+
];
|
|
9359
|
+
var _ifaceLists;
|
|
9360
|
+
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
9361
|
+
if (!_ifaceLists)
|
|
9362
|
+
return;
|
|
9363
|
+
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
9364
|
+
if (aKey === listKey && bKey === ifaceKey) {
|
|
9365
|
+
const negated = aVal.startsWith("!");
|
|
9366
|
+
const listName = negated ? aVal.slice(1) : aVal;
|
|
9367
|
+
const members = _ifaceLists.get(listName);
|
|
9368
|
+
if (!members)
|
|
9369
|
+
return;
|
|
9370
|
+
const isMember = members.has(bVal);
|
|
9371
|
+
return negated ? !isMember : isMember;
|
|
9372
|
+
}
|
|
9373
|
+
}
|
|
9374
|
+
return;
|
|
9375
|
+
}
|
|
9341
9376
|
function aCoversB(a, b) {
|
|
9342
9377
|
for (const [k, v] of Object.entries(a.match)) {
|
|
9343
9378
|
const bv = b.match[k];
|
|
9344
|
-
if (bv
|
|
9379
|
+
if (bv !== undefined) {
|
|
9380
|
+
if (!covers(k, v, bv))
|
|
9381
|
+
return false;
|
|
9382
|
+
continue;
|
|
9383
|
+
}
|
|
9384
|
+
let crossCovered = false;
|
|
9385
|
+
for (const [bk, bval] of Object.entries(b.match)) {
|
|
9386
|
+
const result = interfaceListCovers(k, v, bk, bval);
|
|
9387
|
+
if (result === true) {
|
|
9388
|
+
crossCovered = true;
|
|
9389
|
+
break;
|
|
9390
|
+
}
|
|
9391
|
+
}
|
|
9392
|
+
if (!crossCovered)
|
|
9345
9393
|
return false;
|
|
9346
9394
|
}
|
|
9347
9395
|
return true;
|
|
@@ -9518,6 +9566,7 @@ function grade2(score) {
|
|
|
9518
9566
|
return "critical";
|
|
9519
9567
|
}
|
|
9520
9568
|
function auditFirewall(input) {
|
|
9569
|
+
_ifaceLists = input.interfaceLists;
|
|
9521
9570
|
const findings = [];
|
|
9522
9571
|
if (input.filter)
|
|
9523
9572
|
findings.push(...auditFilter(input.filter));
|
|
@@ -9566,6 +9615,25 @@ async function fetchRules(path, ctx) {
|
|
|
9566
9615
|
return [];
|
|
9567
9616
|
return parseRecords(out).rows;
|
|
9568
9617
|
}
|
|
9618
|
+
async function fetchInterfaceListMembers(ctx) {
|
|
9619
|
+
const out = await executeMikrotikCommand("/interface list member print detail", ctx);
|
|
9620
|
+
const members = new Map;
|
|
9621
|
+
if (looksLikeError(out) || isEmpty(out))
|
|
9622
|
+
return members;
|
|
9623
|
+
for (const row of parseRecords(out).rows) {
|
|
9624
|
+
const list = row.list;
|
|
9625
|
+
const iface = row.interface;
|
|
9626
|
+
if (!list || !iface)
|
|
9627
|
+
continue;
|
|
9628
|
+
let set = members.get(list);
|
|
9629
|
+
if (!set) {
|
|
9630
|
+
set = new Set;
|
|
9631
|
+
members.set(list, set);
|
|
9632
|
+
}
|
|
9633
|
+
set.add(iface);
|
|
9634
|
+
}
|
|
9635
|
+
return members;
|
|
9636
|
+
}
|
|
9569
9637
|
var firewallAuditTools = [
|
|
9570
9638
|
defineTool({
|
|
9571
9639
|
name: "firewall_audit",
|
|
@@ -9580,10 +9648,16 @@ var firewallAuditTools = [
|
|
|
9580
9648
|
async handler(a, ctx) {
|
|
9581
9649
|
const device = resolveDeviceName(ctx.device);
|
|
9582
9650
|
ctx.info(`Auditing firewall for '${device}'`);
|
|
9583
|
-
const
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9651
|
+
const [filterRows, natRows, mangleRows, interfaceLists] = await Promise.all([
|
|
9652
|
+
fetchRules("/ip firewall filter", ctx),
|
|
9653
|
+
a.include_nat ? fetchRules("/ip firewall nat", ctx) : Promise.resolve(undefined),
|
|
9654
|
+
a.include_mangle ? fetchRules("/ip firewall mangle", ctx) : Promise.resolve(undefined),
|
|
9655
|
+
fetchInterfaceListMembers(ctx)
|
|
9656
|
+
]);
|
|
9657
|
+
const filter = rulesFromRows(filterRows);
|
|
9658
|
+
const nat = natRows ? rulesFromRows(natRows) : undefined;
|
|
9659
|
+
const mangle = mangleRows ? rulesFromRows(mangleRows) : undefined;
|
|
9660
|
+
const report = auditFirewall({ filter, nat, mangle, interfaceLists });
|
|
9587
9661
|
const structuredContent = {
|
|
9588
9662
|
__mikrotikView: "firewall-audit",
|
|
9589
9663
|
device,
|
|
@@ -10048,6 +10122,22 @@ Management access may be partially restricted \u2014 review immediately.`;
|
|
|
10048
10122
|
// src/tools/firewall-filter.ts
|
|
10049
10123
|
import { z as z28 } from "zod";
|
|
10050
10124
|
var isDigits = (s) => /^\d+$/.test(s);
|
|
10125
|
+
async function resolveFilterRuleId(ruleId, ctx) {
|
|
10126
|
+
if (/^\d+$/.test(ruleId)) {
|
|
10127
|
+
const id = `*${ruleId}`;
|
|
10128
|
+
const byId = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${id}`, ctx);
|
|
10129
|
+
if (byId.trim() !== "0")
|
|
10130
|
+
return id;
|
|
10131
|
+
const idsRaw = await executeMikrotikCommand(`:foreach i in=[/ip firewall filter find] do={:put $i}`, ctx);
|
|
10132
|
+
if (isEmpty(idsRaw))
|
|
10133
|
+
return null;
|
|
10134
|
+
const ids = idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
10135
|
+
const pos = Number.parseInt(ruleId, 10);
|
|
10136
|
+
return pos >= 0 && pos < ids.length ? ids[pos] : null;
|
|
10137
|
+
}
|
|
10138
|
+
const count = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${ruleId}`, ctx);
|
|
10139
|
+
return count.trim() !== "0" ? ruleId : null;
|
|
10140
|
+
}
|
|
10051
10141
|
async function updateFilterRule(a, ctx) {
|
|
10052
10142
|
ctx.info(`Updating firewall filter rule: rule_id=${a.rule_id}`);
|
|
10053
10143
|
const updates = [];
|
|
@@ -10116,7 +10206,9 @@ async function updateFilterRule(a, ctx) {
|
|
|
10116
10206
|
}
|
|
10117
10207
|
if (updates.length === 0)
|
|
10118
10208
|
return "No updates specified.";
|
|
10119
|
-
const id =
|
|
10209
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10210
|
+
if (!id)
|
|
10211
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10120
10212
|
const cmd = `/ip firewall filter set ${id} ${updates.join(" ")}`;
|
|
10121
10213
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
10122
10214
|
if (looksLikeError(result))
|
|
@@ -10260,7 +10352,7 @@ ${details}`;
|
|
|
10260
10352
|
filters.push("invalid=yes");
|
|
10261
10353
|
if (a.dynamic_only)
|
|
10262
10354
|
filters.push("dynamic=yes");
|
|
10263
|
-
const result = await executeMikrotikCommand(`/ip firewall filter print${whereClause(filters)}`, ctx);
|
|
10355
|
+
const result = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
|
|
10264
10356
|
return isEmpty(result) ? "No firewall filter rules found matching the criteria." : `FIREWALL FILTER RULES:
|
|
10265
10357
|
|
|
10266
10358
|
${result}`;
|
|
@@ -10270,15 +10362,17 @@ ${result}`;
|
|
|
10270
10362
|
name: "get_filter_rule",
|
|
10271
10363
|
title: "Get Firewall Filter Rule",
|
|
10272
10364
|
annotations: READ,
|
|
10273
|
-
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: the `.id` from list_filter_rules e.g. "*
|
|
10365
|
+
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.",
|
|
10274
10366
|
inputSchema: {
|
|
10275
|
-
rule_id: z28.string().describe('Rule
|
|
10367
|
+
rule_id: z28.string().describe('Rule .id e.g. "*1F", or bare position number e.g. "3"')
|
|
10276
10368
|
},
|
|
10277
10369
|
async handler(a, ctx) {
|
|
10278
10370
|
ctx.info(`Getting firewall filter rule details: rule_id=${a.rule_id}`);
|
|
10279
|
-
const id =
|
|
10371
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10372
|
+
if (!id)
|
|
10373
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10280
10374
|
const result = await executeMikrotikCommand(`/ip firewall filter print detail where .id=${id}`, ctx);
|
|
10281
|
-
return isEmpty(result) ? `Firewall filter rule
|
|
10375
|
+
return isEmpty(result) ? `Firewall filter rule '${a.rule_id}' not found.` : `FIREWALL FILTER RULE DETAILS:
|
|
10282
10376
|
|
|
10283
10377
|
${result}`;
|
|
10284
10378
|
}
|
|
@@ -10353,14 +10447,13 @@ ${result}`;
|
|
|
10353
10447
|
inputSchema: { rule_id: z28.string() },
|
|
10354
10448
|
async handler(a, ctx) {
|
|
10355
10449
|
ctx.info(`Removing firewall filter rule: rule_id=${a.rule_id}`);
|
|
10356
|
-
const id =
|
|
10357
|
-
|
|
10358
|
-
|
|
10359
|
-
return `Firewall filter rule with ID '${a.rule_id}' not found.`;
|
|
10450
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10451
|
+
if (!id)
|
|
10452
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10360
10453
|
const result = await executeMikrotikCommand(`/ip firewall filter remove ${id}`, ctx);
|
|
10361
10454
|
if (looksLikeError(result))
|
|
10362
10455
|
return `Failed to remove firewall filter rule: ${result}`;
|
|
10363
|
-
return `Firewall filter rule
|
|
10456
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) removed successfully.`;
|
|
10364
10457
|
}
|
|
10365
10458
|
}),
|
|
10366
10459
|
defineTool({
|
|
@@ -10374,14 +10467,13 @@ ${result}`;
|
|
|
10374
10467
|
},
|
|
10375
10468
|
async handler(a, ctx) {
|
|
10376
10469
|
ctx.info(`Moving firewall filter rule: rule_id=${a.rule_id} to position ${a.destination}`);
|
|
10377
|
-
const id =
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
return `Firewall filter rule with ID '${a.rule_id}' not found.`;
|
|
10470
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10471
|
+
if (!id)
|
|
10472
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10381
10473
|
const result = await executeMikrotikCommand(`/ip firewall filter move ${id} destination=${a.destination}`, ctx);
|
|
10382
10474
|
if (looksLikeError(result))
|
|
10383
10475
|
return `Failed to move firewall filter rule: ${result}`;
|
|
10384
|
-
return `Firewall filter rule
|
|
10476
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${a.destination}.`;
|
|
10385
10477
|
}
|
|
10386
10478
|
}),
|
|
10387
10479
|
defineTool({
|
|
@@ -20657,13 +20749,27 @@ function analyzeRootCause(data) {
|
|
|
20657
20749
|
}
|
|
20658
20750
|
}
|
|
20659
20751
|
if (downInterfaces.length === 0 && errorInterfaces.length === 0) {
|
|
20752
|
+
if (data.interfaces.length === 0) {
|
|
20753
|
+
evidence.push({
|
|
20754
|
+
dimension: "interfaces",
|
|
20755
|
+
severity: "warning",
|
|
20756
|
+
summary: "Interface data unavailable \u2014 the device returned no parseable interface records. " + "Run `/interface print detail` manually to verify."
|
|
20757
|
+
});
|
|
20758
|
+
} else {
|
|
20759
|
+
evidence.push({
|
|
20760
|
+
dimension: "interfaces",
|
|
20761
|
+
severity: "ok",
|
|
20762
|
+
summary: `All ${data.interfaces.length} interfaces healthy`
|
|
20763
|
+
});
|
|
20764
|
+
}
|
|
20765
|
+
}
|
|
20766
|
+
if (data.routeCount === 0) {
|
|
20660
20767
|
evidence.push({
|
|
20661
|
-
dimension: "
|
|
20662
|
-
severity: "
|
|
20663
|
-
summary:
|
|
20768
|
+
dimension: "routing",
|
|
20769
|
+
severity: "warning",
|
|
20770
|
+
summary: "Route data unavailable \u2014 the device returned no parseable route records. " + "Run `/ip route print detail` manually to verify."
|
|
20664
20771
|
});
|
|
20665
|
-
}
|
|
20666
|
-
if (!data.defaultRouteExists) {
|
|
20772
|
+
} else if (!data.defaultRouteExists) {
|
|
20667
20773
|
evidence.push({
|
|
20668
20774
|
dimension: "routing",
|
|
20669
20775
|
severity: "critical",
|
|
@@ -20676,7 +20782,7 @@ function analyzeRootCause(data) {
|
|
|
20676
20782
|
summary: `Default route present, ${data.routeCount} total routes`
|
|
20677
20783
|
});
|
|
20678
20784
|
}
|
|
20679
|
-
const ospfDown = data.ospfNeighbors.filter((n) => n.state.toLowerCase() !== "full");
|
|
20785
|
+
const ospfDown = data.ospfNeighbors.filter((n) => (n.id || n.address) && n.state.toLowerCase() !== "full");
|
|
20680
20786
|
for (const n of ospfDown) {
|
|
20681
20787
|
evidence.push({
|
|
20682
20788
|
dimension: "routing",
|
|
@@ -20685,7 +20791,7 @@ function analyzeRootCause(data) {
|
|
|
20685
20791
|
reference: n.id
|
|
20686
20792
|
});
|
|
20687
20793
|
}
|
|
20688
|
-
const bgpDown = data.bgpPeers.filter((p) => !p.state.toLowerCase().includes("established"));
|
|
20794
|
+
const bgpDown = data.bgpPeers.filter((p) => (p.id || p.address) && !p.state.toLowerCase().includes("established"));
|
|
20689
20795
|
for (const p of bgpDown) {
|
|
20690
20796
|
evidence.push({
|
|
20691
20797
|
dimension: "routing",
|
|
@@ -20788,7 +20894,13 @@ function analyzeRootCause(data) {
|
|
|
20788
20894
|
summary: "No DNS servers configured"
|
|
20789
20895
|
});
|
|
20790
20896
|
}
|
|
20791
|
-
if (data.cpuLoad
|
|
20897
|
+
if (data.cpuLoad === 0 && data.memoryUsedPct === 0 && !data.rosVersion && !data.uptime) {
|
|
20898
|
+
evidence.push({
|
|
20899
|
+
dimension: "resources",
|
|
20900
|
+
severity: "warning",
|
|
20901
|
+
summary: "System resource data unavailable \u2014 CPU 0%, memory 0%, no version or uptime. " + "The `/system resource print` output may not have been parsed correctly."
|
|
20902
|
+
});
|
|
20903
|
+
} else if (data.cpuLoad > 90) {
|
|
20792
20904
|
evidence.push({
|
|
20793
20905
|
dimension: "resources",
|
|
20794
20906
|
severity: "critical",
|
|
@@ -20807,24 +20919,26 @@ function analyzeRootCause(data) {
|
|
|
20807
20919
|
summary: `CPU load: ${data.cpuLoad}%`
|
|
20808
20920
|
});
|
|
20809
20921
|
}
|
|
20810
|
-
if (data.memoryUsedPct >
|
|
20811
|
-
|
|
20812
|
-
|
|
20813
|
-
|
|
20814
|
-
|
|
20815
|
-
|
|
20816
|
-
|
|
20817
|
-
|
|
20818
|
-
|
|
20819
|
-
|
|
20820
|
-
|
|
20821
|
-
|
|
20822
|
-
|
|
20823
|
-
|
|
20824
|
-
|
|
20825
|
-
|
|
20826
|
-
|
|
20827
|
-
|
|
20922
|
+
if (data.rosVersion || data.uptime || data.cpuLoad > 0 || data.memoryUsedPct > 0) {
|
|
20923
|
+
if (data.memoryUsedPct > 90) {
|
|
20924
|
+
evidence.push({
|
|
20925
|
+
dimension: "resources",
|
|
20926
|
+
severity: "critical",
|
|
20927
|
+
summary: `Memory critically low: ${data.memoryUsedPct}% used`
|
|
20928
|
+
});
|
|
20929
|
+
} else if (data.memoryUsedPct > 75) {
|
|
20930
|
+
evidence.push({
|
|
20931
|
+
dimension: "resources",
|
|
20932
|
+
severity: "warning",
|
|
20933
|
+
summary: `Memory pressure: ${data.memoryUsedPct}% used`
|
|
20934
|
+
});
|
|
20935
|
+
} else {
|
|
20936
|
+
evidence.push({
|
|
20937
|
+
dimension: "resources",
|
|
20938
|
+
severity: "ok",
|
|
20939
|
+
summary: `Memory: ${data.memoryUsedPct}% used`
|
|
20940
|
+
});
|
|
20941
|
+
}
|
|
20828
20942
|
}
|
|
20829
20943
|
const errorLogs = data.relevantLogs.filter((l) => l.topics.includes("error") || l.topics.includes("critical") || l.topics.includes("warning"));
|
|
20830
20944
|
const firewallLogs = data.relevantLogs.filter((l) => l.topics.includes("firewall"));
|
|
@@ -20861,14 +20975,24 @@ function analyzeRootCause(data) {
|
|
|
20861
20975
|
summary: "No concerning log entries in the last 10 minutes"
|
|
20862
20976
|
});
|
|
20863
20977
|
}
|
|
20978
|
+
const isServerBinding = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
20864
20979
|
const downTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled);
|
|
20865
20980
|
for (const t of downTunnels) {
|
|
20866
|
-
|
|
20867
|
-
|
|
20868
|
-
|
|
20869
|
-
|
|
20870
|
-
|
|
20871
|
-
|
|
20981
|
+
if (isServerBinding(t)) {
|
|
20982
|
+
evidence.push({
|
|
20983
|
+
dimension: "vpn",
|
|
20984
|
+
severity: "info",
|
|
20985
|
+
summary: `Server binding ${t.name} (${t.type}) is idle \u2014 no active client session`,
|
|
20986
|
+
reference: t.name
|
|
20987
|
+
});
|
|
20988
|
+
} else {
|
|
20989
|
+
evidence.push({
|
|
20990
|
+
dimension: "vpn",
|
|
20991
|
+
severity: "critical",
|
|
20992
|
+
summary: `Tunnel ${t.name} (${t.type}) is down`,
|
|
20993
|
+
reference: t.name
|
|
20994
|
+
});
|
|
20995
|
+
}
|
|
20872
20996
|
}
|
|
20873
20997
|
if (data.tunnelInterfaces.length > 0 && downTunnels.length === 0) {
|
|
20874
20998
|
evidence.push({
|
|
@@ -20878,6 +21002,19 @@ function analyzeRootCause(data) {
|
|
|
20878
21002
|
});
|
|
20879
21003
|
}
|
|
20880
21004
|
correlateRootCauses(data, evidence, rootCauses);
|
|
21005
|
+
if (data.ping && data.ping.lossPct === 0) {
|
|
21006
|
+
const contradicted = new Set([
|
|
21007
|
+
"Missing default route",
|
|
21008
|
+
"Missing source NAT / masquerade",
|
|
21009
|
+
"Interface link failure"
|
|
21010
|
+
]);
|
|
21011
|
+
for (const rc of rootCauses) {
|
|
21012
|
+
if (contradicted.has(rc.cause) && rc.confidence !== "low") {
|
|
21013
|
+
rc.confidence = "low";
|
|
21014
|
+
rc.explanation += " (Note: ping to the target succeeded with 0% loss, which contradicts this hypothesis.)";
|
|
21015
|
+
}
|
|
21016
|
+
}
|
|
21017
|
+
}
|
|
20881
21018
|
rootCauses.sort((a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]);
|
|
20882
21019
|
const dimensionSummary = ALL_DIMENSIONS.map((dim) => {
|
|
20883
21020
|
const dimEvidence = evidence.filter((e) => e.dimension === dim);
|
|
@@ -21016,14 +21153,15 @@ function correlateRootCauses(data, evidence, causes) {
|
|
|
21016
21153
|
});
|
|
21017
21154
|
}
|
|
21018
21155
|
}
|
|
21019
|
-
const
|
|
21020
|
-
|
|
21156
|
+
const isServerTunnel = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
21157
|
+
const realDownTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled && !isServerTunnel(t));
|
|
21158
|
+
if (realDownTunnels.length > 0) {
|
|
21021
21159
|
causes.push({
|
|
21022
21160
|
cause: "VPN/tunnel interface down",
|
|
21023
|
-
explanation: `Tunnel(s) ${
|
|
21161
|
+
explanation: `Tunnel(s) ${realDownTunnels.map((t) => `${t.name} (${t.type})`).join(", ")} are down. ` + "Traffic destined for remote networks over these tunnels will be black-holed.",
|
|
21024
21162
|
confidence: "high",
|
|
21025
21163
|
evidence: evidence.filter((e) => e.dimension === "vpn" && e.severity === "critical"),
|
|
21026
|
-
fixes:
|
|
21164
|
+
fixes: realDownTunnels.map((t) => `/interface enable [find name="${t.name}"]`),
|
|
21027
21165
|
dimensions: ["vpn"]
|
|
21028
21166
|
});
|
|
21029
21167
|
}
|
|
@@ -21252,7 +21390,7 @@ async function collectRoutes(ctx) {
|
|
|
21252
21390
|
}
|
|
21253
21391
|
async function collectOspfNeighbors(ctx) {
|
|
21254
21392
|
const raw = await safe("/routing ospf neighbor print detail", ctx);
|
|
21255
|
-
if (!raw)
|
|
21393
|
+
if (!raw || isEmpty(raw))
|
|
21256
21394
|
return [];
|
|
21257
21395
|
return parseRecords(raw).rows.map((r) => ({
|
|
21258
21396
|
id: r["neighbor-id"] ?? r.router ?? "",
|
|
@@ -21260,13 +21398,13 @@ async function collectOspfNeighbors(ctx) {
|
|
|
21260
21398
|
state: r.state ?? "",
|
|
21261
21399
|
interface: r.interface ?? "",
|
|
21262
21400
|
uptime: r.uptime
|
|
21263
|
-
}));
|
|
21401
|
+
})).filter((n) => n.id || n.address);
|
|
21264
21402
|
}
|
|
21265
21403
|
async function collectBgpPeers(ctx) {
|
|
21266
21404
|
const raw = await safe("/routing bgp session print detail", ctx);
|
|
21267
|
-
if (!raw) {
|
|
21405
|
+
if (!raw || isEmpty(raw)) {
|
|
21268
21406
|
const raw2 = await safe("/routing bgp peer print detail", ctx);
|
|
21269
|
-
if (!raw2)
|
|
21407
|
+
if (!raw2 || isEmpty(raw2))
|
|
21270
21408
|
return [];
|
|
21271
21409
|
return parseRecords(raw2).rows.map((r) => ({
|
|
21272
21410
|
id: r.name ?? "",
|
|
@@ -21274,7 +21412,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21274
21412
|
state: r.state ?? "",
|
|
21275
21413
|
interface: r.interface ?? "",
|
|
21276
21414
|
uptime: r.uptime
|
|
21277
|
-
}));
|
|
21415
|
+
})).filter((p) => p.id || p.address);
|
|
21278
21416
|
}
|
|
21279
21417
|
return parseRecords(raw).rows.map((r) => ({
|
|
21280
21418
|
id: r.name ?? r["remote.address"] ?? "",
|
|
@@ -21282,7 +21420,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21282
21420
|
state: r.state ?? r.established ?? "",
|
|
21283
21421
|
interface: r.interface ?? "",
|
|
21284
21422
|
uptime: r.uptime
|
|
21285
|
-
}));
|
|
21423
|
+
})).filter((p) => p.id || p.address);
|
|
21286
21424
|
}
|
|
21287
21425
|
async function collectFirewallRules(target, ctx) {
|
|
21288
21426
|
const raw = await safe("/ip firewall filter print detail", ctx);
|
|
@@ -21382,7 +21520,7 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21382
21520
|
arpEntries,
|
|
21383
21521
|
dhcpLeases,
|
|
21384
21522
|
dnsResult,
|
|
21385
|
-
|
|
21523
|
+
dnsSettingsParts,
|
|
21386
21524
|
resourceRaw,
|
|
21387
21525
|
logs,
|
|
21388
21526
|
tunnels
|
|
@@ -21398,13 +21536,17 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21398
21536
|
dims.has("arp_dhcp") ? collectArp(ctx) : Promise.resolve([]),
|
|
21399
21537
|
dims.has("arp_dhcp") ? collectDhcpLeases(ctx) : Promise.resolve([]),
|
|
21400
21538
|
dims.has("dns") && !isIpLike(target) ? safe(`[:resolve ${quoteValue(target)}]`, ctx) : Promise.resolve(undefined),
|
|
21401
|
-
dims.has("dns") ?
|
|
21539
|
+
dims.has("dns") ? Promise.all([
|
|
21540
|
+
safe(":put [/ip dns get servers]", ctx),
|
|
21541
|
+
safe(":put [/ip dns get dynamic-servers]", ctx),
|
|
21542
|
+
safe(":put [/ip dns get allow-remote-requests]", ctx)
|
|
21543
|
+
]) : Promise.resolve(["", "", ""]),
|
|
21402
21544
|
dims.has("resources") ? safe("/system resource print", ctx) : Promise.resolve(""),
|
|
21403
21545
|
dims.has("logs") ? collectLogs(target, ctx) : Promise.resolve([]),
|
|
21404
21546
|
dims.has("vpn") ? collectTunnels(ctx) : Promise.resolve([])
|
|
21405
21547
|
]);
|
|
21406
21548
|
const ping = pingResult ? parsePingSummary(pingResult) ?? undefined : undefined;
|
|
21407
|
-
const
|
|
21549
|
+
const [dnsServersRaw, dnsDynamicRaw, dnsAllowRemoteRaw] = dnsSettingsParts;
|
|
21408
21550
|
const resKv = parseKeyValues(resourceRaw);
|
|
21409
21551
|
const totalMem = parseSize(resKv["total-memory"]) ?? 0;
|
|
21410
21552
|
const freeMem = parseSize(resKv["free-memory"]) ?? 0;
|
|
@@ -21426,8 +21568,8 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21426
21568
|
arpEntries,
|
|
21427
21569
|
dhcpLeases,
|
|
21428
21570
|
dnsResolveResult: dnsResult ?? undefined,
|
|
21429
|
-
dnsServers: [
|
|
21430
|
-
dnsAllowRemote: (
|
|
21571
|
+
dnsServers: [dnsServersRaw.trim(), dnsDynamicRaw.trim()].filter(Boolean).join(",") || "",
|
|
21572
|
+
dnsAllowRemote: dnsAllowRemoteRaw.trim().toLowerCase() === "yes" || dnsAllowRemoteRaw.trim().toLowerCase() === "true",
|
|
21431
21573
|
cpuLoad: parsePercent(resKv["cpu-load"]) ?? 0,
|
|
21432
21574
|
memoryUsedPct: memUsedPct,
|
|
21433
21575
|
uptime: resKv.uptime ?? "",
|