@usex/mikrotik-mcp 3.54.0 → 3.55.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 +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/{cli-njttbdf8.js → cli-44v6pb38.js} +198 -70
- package/dist/shared/{cli-xt3qvpz0.js → cli-yxah49n1.js} +1 -1
- package/dist/shared/{library-hk90ad18.js → library-83q9t27c.js} +198 -70
- package/dist/shared/{library-84y0nvhr.js → library-cs13jqp9.js} +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
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-83q9t27c.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";
|
|
@@ -1762,7 +1762,7 @@ function parseKvTokens(chunk) {
|
|
|
1762
1762
|
return out;
|
|
1763
1763
|
}
|
|
1764
1764
|
var INDEX_LINE = /^\s*(\d+)\s+(.*)$/;
|
|
1765
|
-
var LEADING_FLAGS = /^([A-Z]
|
|
1765
|
+
var LEADING_FLAGS = /^([A-Z][A-Za-z]*)(?=\s|$)/;
|
|
1766
1766
|
function unionColumns(rows) {
|
|
1767
1767
|
const seen = new Set;
|
|
1768
1768
|
const cols = [];
|
|
@@ -8313,7 +8313,7 @@ var cache = null;
|
|
|
8313
8313
|
async function gateway() {
|
|
8314
8314
|
if (cache)
|
|
8315
8315
|
return cache;
|
|
8316
|
-
const { moduleCatalog } = await import("./cli-
|
|
8316
|
+
const { moduleCatalog } = await import("./cli-yxah49n1.js");
|
|
8317
8317
|
const forIndex = [];
|
|
8318
8318
|
const byName = new Map;
|
|
8319
8319
|
for (const mod of moduleCatalog) {
|
|
@@ -9338,10 +9338,44 @@ function covers(key, aVal, bVal) {
|
|
|
9338
9338
|
return cidrContains(aVal, bVal);
|
|
9339
9339
|
return false;
|
|
9340
9340
|
}
|
|
9341
|
+
var INTERFACE_LIST_PAIRS = [
|
|
9342
|
+
["in-interface-list", "in-interface"],
|
|
9343
|
+
["out-interface-list", "out-interface"]
|
|
9344
|
+
];
|
|
9345
|
+
var _ifaceLists;
|
|
9346
|
+
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
9347
|
+
if (!_ifaceLists)
|
|
9348
|
+
return;
|
|
9349
|
+
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
9350
|
+
if (aKey === listKey && bKey === ifaceKey) {
|
|
9351
|
+
const negated = aVal.startsWith("!");
|
|
9352
|
+
const listName = negated ? aVal.slice(1) : aVal;
|
|
9353
|
+
const members = _ifaceLists.get(listName);
|
|
9354
|
+
if (!members)
|
|
9355
|
+
return;
|
|
9356
|
+
const isMember = members.has(bVal);
|
|
9357
|
+
return negated ? !isMember : isMember;
|
|
9358
|
+
}
|
|
9359
|
+
}
|
|
9360
|
+
return;
|
|
9361
|
+
}
|
|
9341
9362
|
function aCoversB(a, b) {
|
|
9342
9363
|
for (const [k, v] of Object.entries(a.match)) {
|
|
9343
9364
|
const bv = b.match[k];
|
|
9344
|
-
if (bv
|
|
9365
|
+
if (bv !== undefined) {
|
|
9366
|
+
if (!covers(k, v, bv))
|
|
9367
|
+
return false;
|
|
9368
|
+
continue;
|
|
9369
|
+
}
|
|
9370
|
+
let crossCovered = false;
|
|
9371
|
+
for (const [bk, bval] of Object.entries(b.match)) {
|
|
9372
|
+
const result = interfaceListCovers(k, v, bk, bval);
|
|
9373
|
+
if (result === true) {
|
|
9374
|
+
crossCovered = true;
|
|
9375
|
+
break;
|
|
9376
|
+
}
|
|
9377
|
+
}
|
|
9378
|
+
if (!crossCovered)
|
|
9345
9379
|
return false;
|
|
9346
9380
|
}
|
|
9347
9381
|
return true;
|
|
@@ -9518,6 +9552,7 @@ function grade2(score) {
|
|
|
9518
9552
|
return "critical";
|
|
9519
9553
|
}
|
|
9520
9554
|
function auditFirewall(input) {
|
|
9555
|
+
_ifaceLists = input.interfaceLists;
|
|
9521
9556
|
const findings = [];
|
|
9522
9557
|
if (input.filter)
|
|
9523
9558
|
findings.push(...auditFilter(input.filter));
|
|
@@ -9566,6 +9601,25 @@ async function fetchRules(path, ctx) {
|
|
|
9566
9601
|
return [];
|
|
9567
9602
|
return parseRecords(out).rows;
|
|
9568
9603
|
}
|
|
9604
|
+
async function fetchInterfaceListMembers(ctx) {
|
|
9605
|
+
const out = await executeMikrotikCommand("/interface list member print detail", ctx);
|
|
9606
|
+
const members = new Map;
|
|
9607
|
+
if (looksLikeError(out) || isEmpty(out))
|
|
9608
|
+
return members;
|
|
9609
|
+
for (const row of parseRecords(out).rows) {
|
|
9610
|
+
const list = row.list;
|
|
9611
|
+
const iface = row.interface;
|
|
9612
|
+
if (!list || !iface)
|
|
9613
|
+
continue;
|
|
9614
|
+
let set = members.get(list);
|
|
9615
|
+
if (!set) {
|
|
9616
|
+
set = new Set;
|
|
9617
|
+
members.set(list, set);
|
|
9618
|
+
}
|
|
9619
|
+
set.add(iface);
|
|
9620
|
+
}
|
|
9621
|
+
return members;
|
|
9622
|
+
}
|
|
9569
9623
|
var firewallAuditTools = [
|
|
9570
9624
|
defineTool({
|
|
9571
9625
|
name: "firewall_audit",
|
|
@@ -9580,10 +9634,16 @@ var firewallAuditTools = [
|
|
|
9580
9634
|
async handler(a, ctx) {
|
|
9581
9635
|
const device = resolveDeviceName(ctx.device);
|
|
9582
9636
|
ctx.info(`Auditing firewall for '${device}'`);
|
|
9583
|
-
const
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9637
|
+
const [filterRows, natRows, mangleRows, interfaceLists] = await Promise.all([
|
|
9638
|
+
fetchRules("/ip firewall filter", ctx),
|
|
9639
|
+
a.include_nat ? fetchRules("/ip firewall nat", ctx) : Promise.resolve(undefined),
|
|
9640
|
+
a.include_mangle ? fetchRules("/ip firewall mangle", ctx) : Promise.resolve(undefined),
|
|
9641
|
+
fetchInterfaceListMembers(ctx)
|
|
9642
|
+
]);
|
|
9643
|
+
const filter = rulesFromRows(filterRows);
|
|
9644
|
+
const nat = natRows ? rulesFromRows(natRows) : undefined;
|
|
9645
|
+
const mangle = mangleRows ? rulesFromRows(mangleRows) : undefined;
|
|
9646
|
+
const report = auditFirewall({ filter, nat, mangle, interfaceLists });
|
|
9587
9647
|
const structuredContent = {
|
|
9588
9648
|
__mikrotikView: "firewall-audit",
|
|
9589
9649
|
device,
|
|
@@ -10048,6 +10108,22 @@ Management access may be partially restricted \u2014 review immediately.`;
|
|
|
10048
10108
|
// src/tools/firewall-filter.ts
|
|
10049
10109
|
import { z as z28 } from "zod";
|
|
10050
10110
|
var isDigits = (s) => /^\d+$/.test(s);
|
|
10111
|
+
async function resolveFilterRuleId(ruleId, ctx) {
|
|
10112
|
+
if (/^\d+$/.test(ruleId)) {
|
|
10113
|
+
const id = `*${ruleId}`;
|
|
10114
|
+
const byId = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${id}`, ctx);
|
|
10115
|
+
if (byId.trim() !== "0")
|
|
10116
|
+
return id;
|
|
10117
|
+
const idsRaw = await executeMikrotikCommand(`:foreach i in=[/ip firewall filter find] do={:put $i}`, ctx);
|
|
10118
|
+
if (isEmpty(idsRaw))
|
|
10119
|
+
return null;
|
|
10120
|
+
const ids = idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
10121
|
+
const pos = Number.parseInt(ruleId, 10);
|
|
10122
|
+
return pos >= 0 && pos < ids.length ? ids[pos] : null;
|
|
10123
|
+
}
|
|
10124
|
+
const count = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${ruleId}`, ctx);
|
|
10125
|
+
return count.trim() !== "0" ? ruleId : null;
|
|
10126
|
+
}
|
|
10051
10127
|
async function updateFilterRule(a, ctx) {
|
|
10052
10128
|
ctx.info(`Updating firewall filter rule: rule_id=${a.rule_id}`);
|
|
10053
10129
|
const updates = [];
|
|
@@ -10116,7 +10192,9 @@ async function updateFilterRule(a, ctx) {
|
|
|
10116
10192
|
}
|
|
10117
10193
|
if (updates.length === 0)
|
|
10118
10194
|
return "No updates specified.";
|
|
10119
|
-
const id =
|
|
10195
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10196
|
+
if (!id)
|
|
10197
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10120
10198
|
const cmd = `/ip firewall filter set ${id} ${updates.join(" ")}`;
|
|
10121
10199
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
10122
10200
|
if (looksLikeError(result))
|
|
@@ -10260,7 +10338,7 @@ ${details}`;
|
|
|
10260
10338
|
filters.push("invalid=yes");
|
|
10261
10339
|
if (a.dynamic_only)
|
|
10262
10340
|
filters.push("dynamic=yes");
|
|
10263
|
-
const result = await executeMikrotikCommand(`/ip firewall filter print${whereClause(filters)}`, ctx);
|
|
10341
|
+
const result = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
|
|
10264
10342
|
return isEmpty(result) ? "No firewall filter rules found matching the criteria." : `FIREWALL FILTER RULES:
|
|
10265
10343
|
|
|
10266
10344
|
${result}`;
|
|
@@ -10270,15 +10348,17 @@ ${result}`;
|
|
|
10270
10348
|
name: "get_filter_rule",
|
|
10271
10349
|
title: "Get Firewall Filter Rule",
|
|
10272
10350
|
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. "*
|
|
10351
|
+
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
10352
|
inputSchema: {
|
|
10275
|
-
rule_id: z28.string().describe('Rule
|
|
10353
|
+
rule_id: z28.string().describe('Rule .id e.g. "*1F", or bare position number e.g. "3"')
|
|
10276
10354
|
},
|
|
10277
10355
|
async handler(a, ctx) {
|
|
10278
10356
|
ctx.info(`Getting firewall filter rule details: rule_id=${a.rule_id}`);
|
|
10279
|
-
const id =
|
|
10357
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10358
|
+
if (!id)
|
|
10359
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10280
10360
|
const result = await executeMikrotikCommand(`/ip firewall filter print detail where .id=${id}`, ctx);
|
|
10281
|
-
return isEmpty(result) ? `Firewall filter rule
|
|
10361
|
+
return isEmpty(result) ? `Firewall filter rule '${a.rule_id}' not found.` : `FIREWALL FILTER RULE DETAILS:
|
|
10282
10362
|
|
|
10283
10363
|
${result}`;
|
|
10284
10364
|
}
|
|
@@ -10353,14 +10433,13 @@ ${result}`;
|
|
|
10353
10433
|
inputSchema: { rule_id: z28.string() },
|
|
10354
10434
|
async handler(a, ctx) {
|
|
10355
10435
|
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.`;
|
|
10436
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10437
|
+
if (!id)
|
|
10438
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10360
10439
|
const result = await executeMikrotikCommand(`/ip firewall filter remove ${id}`, ctx);
|
|
10361
10440
|
if (looksLikeError(result))
|
|
10362
10441
|
return `Failed to remove firewall filter rule: ${result}`;
|
|
10363
|
-
return `Firewall filter rule
|
|
10442
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) removed successfully.`;
|
|
10364
10443
|
}
|
|
10365
10444
|
}),
|
|
10366
10445
|
defineTool({
|
|
@@ -10374,14 +10453,13 @@ ${result}`;
|
|
|
10374
10453
|
},
|
|
10375
10454
|
async handler(a, ctx) {
|
|
10376
10455
|
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.`;
|
|
10456
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10457
|
+
if (!id)
|
|
10458
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10381
10459
|
const result = await executeMikrotikCommand(`/ip firewall filter move ${id} destination=${a.destination}`, ctx);
|
|
10382
10460
|
if (looksLikeError(result))
|
|
10383
10461
|
return `Failed to move firewall filter rule: ${result}`;
|
|
10384
|
-
return `Firewall filter rule
|
|
10462
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${a.destination}.`;
|
|
10385
10463
|
}
|
|
10386
10464
|
}),
|
|
10387
10465
|
defineTool({
|
|
@@ -20657,13 +20735,27 @@ function analyzeRootCause(data) {
|
|
|
20657
20735
|
}
|
|
20658
20736
|
}
|
|
20659
20737
|
if (downInterfaces.length === 0 && errorInterfaces.length === 0) {
|
|
20738
|
+
if (data.interfaces.length === 0) {
|
|
20739
|
+
evidence.push({
|
|
20740
|
+
dimension: "interfaces",
|
|
20741
|
+
severity: "warning",
|
|
20742
|
+
summary: "Interface data unavailable \u2014 the device returned no parseable interface records. " + "Run `/interface print detail` manually to verify."
|
|
20743
|
+
});
|
|
20744
|
+
} else {
|
|
20745
|
+
evidence.push({
|
|
20746
|
+
dimension: "interfaces",
|
|
20747
|
+
severity: "ok",
|
|
20748
|
+
summary: `All ${data.interfaces.length} interfaces healthy`
|
|
20749
|
+
});
|
|
20750
|
+
}
|
|
20751
|
+
}
|
|
20752
|
+
if (data.routeCount === 0) {
|
|
20660
20753
|
evidence.push({
|
|
20661
|
-
dimension: "
|
|
20662
|
-
severity: "
|
|
20663
|
-
summary:
|
|
20754
|
+
dimension: "routing",
|
|
20755
|
+
severity: "warning",
|
|
20756
|
+
summary: "Route data unavailable \u2014 the device returned no parseable route records. " + "Run `/ip route print detail` manually to verify."
|
|
20664
20757
|
});
|
|
20665
|
-
}
|
|
20666
|
-
if (!data.defaultRouteExists) {
|
|
20758
|
+
} else if (!data.defaultRouteExists) {
|
|
20667
20759
|
evidence.push({
|
|
20668
20760
|
dimension: "routing",
|
|
20669
20761
|
severity: "critical",
|
|
@@ -20676,7 +20768,7 @@ function analyzeRootCause(data) {
|
|
|
20676
20768
|
summary: `Default route present, ${data.routeCount} total routes`
|
|
20677
20769
|
});
|
|
20678
20770
|
}
|
|
20679
|
-
const ospfDown = data.ospfNeighbors.filter((n) => n.state.toLowerCase() !== "full");
|
|
20771
|
+
const ospfDown = data.ospfNeighbors.filter((n) => (n.id || n.address) && n.state.toLowerCase() !== "full");
|
|
20680
20772
|
for (const n of ospfDown) {
|
|
20681
20773
|
evidence.push({
|
|
20682
20774
|
dimension: "routing",
|
|
@@ -20685,7 +20777,7 @@ function analyzeRootCause(data) {
|
|
|
20685
20777
|
reference: n.id
|
|
20686
20778
|
});
|
|
20687
20779
|
}
|
|
20688
|
-
const bgpDown = data.bgpPeers.filter((p) => !p.state.toLowerCase().includes("established"));
|
|
20780
|
+
const bgpDown = data.bgpPeers.filter((p) => (p.id || p.address) && !p.state.toLowerCase().includes("established"));
|
|
20689
20781
|
for (const p of bgpDown) {
|
|
20690
20782
|
evidence.push({
|
|
20691
20783
|
dimension: "routing",
|
|
@@ -20788,7 +20880,13 @@ function analyzeRootCause(data) {
|
|
|
20788
20880
|
summary: "No DNS servers configured"
|
|
20789
20881
|
});
|
|
20790
20882
|
}
|
|
20791
|
-
if (data.cpuLoad
|
|
20883
|
+
if (data.cpuLoad === 0 && data.memoryUsedPct === 0 && !data.rosVersion && !data.uptime) {
|
|
20884
|
+
evidence.push({
|
|
20885
|
+
dimension: "resources",
|
|
20886
|
+
severity: "warning",
|
|
20887
|
+
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."
|
|
20888
|
+
});
|
|
20889
|
+
} else if (data.cpuLoad > 90) {
|
|
20792
20890
|
evidence.push({
|
|
20793
20891
|
dimension: "resources",
|
|
20794
20892
|
severity: "critical",
|
|
@@ -20807,24 +20905,26 @@ function analyzeRootCause(data) {
|
|
|
20807
20905
|
summary: `CPU load: ${data.cpuLoad}%`
|
|
20808
20906
|
});
|
|
20809
20907
|
}
|
|
20810
|
-
if (data.memoryUsedPct >
|
|
20811
|
-
|
|
20812
|
-
|
|
20813
|
-
|
|
20814
|
-
|
|
20815
|
-
|
|
20816
|
-
|
|
20817
|
-
|
|
20818
|
-
|
|
20819
|
-
|
|
20820
|
-
|
|
20821
|
-
|
|
20822
|
-
|
|
20823
|
-
|
|
20824
|
-
|
|
20825
|
-
|
|
20826
|
-
|
|
20827
|
-
|
|
20908
|
+
if (data.rosVersion || data.uptime || data.cpuLoad > 0 || data.memoryUsedPct > 0) {
|
|
20909
|
+
if (data.memoryUsedPct > 90) {
|
|
20910
|
+
evidence.push({
|
|
20911
|
+
dimension: "resources",
|
|
20912
|
+
severity: "critical",
|
|
20913
|
+
summary: `Memory critically low: ${data.memoryUsedPct}% used`
|
|
20914
|
+
});
|
|
20915
|
+
} else if (data.memoryUsedPct > 75) {
|
|
20916
|
+
evidence.push({
|
|
20917
|
+
dimension: "resources",
|
|
20918
|
+
severity: "warning",
|
|
20919
|
+
summary: `Memory pressure: ${data.memoryUsedPct}% used`
|
|
20920
|
+
});
|
|
20921
|
+
} else {
|
|
20922
|
+
evidence.push({
|
|
20923
|
+
dimension: "resources",
|
|
20924
|
+
severity: "ok",
|
|
20925
|
+
summary: `Memory: ${data.memoryUsedPct}% used`
|
|
20926
|
+
});
|
|
20927
|
+
}
|
|
20828
20928
|
}
|
|
20829
20929
|
const errorLogs = data.relevantLogs.filter((l) => l.topics.includes("error") || l.topics.includes("critical") || l.topics.includes("warning"));
|
|
20830
20930
|
const firewallLogs = data.relevantLogs.filter((l) => l.topics.includes("firewall"));
|
|
@@ -20861,14 +20961,24 @@ function analyzeRootCause(data) {
|
|
|
20861
20961
|
summary: "No concerning log entries in the last 10 minutes"
|
|
20862
20962
|
});
|
|
20863
20963
|
}
|
|
20964
|
+
const isServerBinding = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
20864
20965
|
const downTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled);
|
|
20865
20966
|
for (const t of downTunnels) {
|
|
20866
|
-
|
|
20867
|
-
|
|
20868
|
-
|
|
20869
|
-
|
|
20870
|
-
|
|
20871
|
-
|
|
20967
|
+
if (isServerBinding(t)) {
|
|
20968
|
+
evidence.push({
|
|
20969
|
+
dimension: "vpn",
|
|
20970
|
+
severity: "info",
|
|
20971
|
+
summary: `Server binding ${t.name} (${t.type}) is idle \u2014 no active client session`,
|
|
20972
|
+
reference: t.name
|
|
20973
|
+
});
|
|
20974
|
+
} else {
|
|
20975
|
+
evidence.push({
|
|
20976
|
+
dimension: "vpn",
|
|
20977
|
+
severity: "critical",
|
|
20978
|
+
summary: `Tunnel ${t.name} (${t.type}) is down`,
|
|
20979
|
+
reference: t.name
|
|
20980
|
+
});
|
|
20981
|
+
}
|
|
20872
20982
|
}
|
|
20873
20983
|
if (data.tunnelInterfaces.length > 0 && downTunnels.length === 0) {
|
|
20874
20984
|
evidence.push({
|
|
@@ -20878,6 +20988,19 @@ function analyzeRootCause(data) {
|
|
|
20878
20988
|
});
|
|
20879
20989
|
}
|
|
20880
20990
|
correlateRootCauses(data, evidence, rootCauses);
|
|
20991
|
+
if (data.ping && data.ping.lossPct === 0) {
|
|
20992
|
+
const contradicted = new Set([
|
|
20993
|
+
"Missing default route",
|
|
20994
|
+
"Missing source NAT / masquerade",
|
|
20995
|
+
"Interface link failure"
|
|
20996
|
+
]);
|
|
20997
|
+
for (const rc of rootCauses) {
|
|
20998
|
+
if (contradicted.has(rc.cause) && rc.confidence !== "low") {
|
|
20999
|
+
rc.confidence = "low";
|
|
21000
|
+
rc.explanation += " (Note: ping to the target succeeded with 0% loss, which contradicts this hypothesis.)";
|
|
21001
|
+
}
|
|
21002
|
+
}
|
|
21003
|
+
}
|
|
20881
21004
|
rootCauses.sort((a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]);
|
|
20882
21005
|
const dimensionSummary = ALL_DIMENSIONS.map((dim) => {
|
|
20883
21006
|
const dimEvidence = evidence.filter((e) => e.dimension === dim);
|
|
@@ -21016,14 +21139,15 @@ function correlateRootCauses(data, evidence, causes) {
|
|
|
21016
21139
|
});
|
|
21017
21140
|
}
|
|
21018
21141
|
}
|
|
21019
|
-
const
|
|
21020
|
-
|
|
21142
|
+
const isServerTunnel = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
21143
|
+
const realDownTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled && !isServerTunnel(t));
|
|
21144
|
+
if (realDownTunnels.length > 0) {
|
|
21021
21145
|
causes.push({
|
|
21022
21146
|
cause: "VPN/tunnel interface down",
|
|
21023
|
-
explanation: `Tunnel(s) ${
|
|
21147
|
+
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
21148
|
confidence: "high",
|
|
21025
21149
|
evidence: evidence.filter((e) => e.dimension === "vpn" && e.severity === "critical"),
|
|
21026
|
-
fixes:
|
|
21150
|
+
fixes: realDownTunnels.map((t) => `/interface enable [find name="${t.name}"]`),
|
|
21027
21151
|
dimensions: ["vpn"]
|
|
21028
21152
|
});
|
|
21029
21153
|
}
|
|
@@ -21252,7 +21376,7 @@ async function collectRoutes(ctx) {
|
|
|
21252
21376
|
}
|
|
21253
21377
|
async function collectOspfNeighbors(ctx) {
|
|
21254
21378
|
const raw = await safe("/routing ospf neighbor print detail", ctx);
|
|
21255
|
-
if (!raw)
|
|
21379
|
+
if (!raw || isEmpty(raw))
|
|
21256
21380
|
return [];
|
|
21257
21381
|
return parseRecords(raw).rows.map((r) => ({
|
|
21258
21382
|
id: r["neighbor-id"] ?? r.router ?? "",
|
|
@@ -21260,13 +21384,13 @@ async function collectOspfNeighbors(ctx) {
|
|
|
21260
21384
|
state: r.state ?? "",
|
|
21261
21385
|
interface: r.interface ?? "",
|
|
21262
21386
|
uptime: r.uptime
|
|
21263
|
-
}));
|
|
21387
|
+
})).filter((n) => n.id || n.address);
|
|
21264
21388
|
}
|
|
21265
21389
|
async function collectBgpPeers(ctx) {
|
|
21266
21390
|
const raw = await safe("/routing bgp session print detail", ctx);
|
|
21267
|
-
if (!raw) {
|
|
21391
|
+
if (!raw || isEmpty(raw)) {
|
|
21268
21392
|
const raw2 = await safe("/routing bgp peer print detail", ctx);
|
|
21269
|
-
if (!raw2)
|
|
21393
|
+
if (!raw2 || isEmpty(raw2))
|
|
21270
21394
|
return [];
|
|
21271
21395
|
return parseRecords(raw2).rows.map((r) => ({
|
|
21272
21396
|
id: r.name ?? "",
|
|
@@ -21274,7 +21398,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21274
21398
|
state: r.state ?? "",
|
|
21275
21399
|
interface: r.interface ?? "",
|
|
21276
21400
|
uptime: r.uptime
|
|
21277
|
-
}));
|
|
21401
|
+
})).filter((p) => p.id || p.address);
|
|
21278
21402
|
}
|
|
21279
21403
|
return parseRecords(raw).rows.map((r) => ({
|
|
21280
21404
|
id: r.name ?? r["remote.address"] ?? "",
|
|
@@ -21282,7 +21406,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21282
21406
|
state: r.state ?? r.established ?? "",
|
|
21283
21407
|
interface: r.interface ?? "",
|
|
21284
21408
|
uptime: r.uptime
|
|
21285
|
-
}));
|
|
21409
|
+
})).filter((p) => p.id || p.address);
|
|
21286
21410
|
}
|
|
21287
21411
|
async function collectFirewallRules(target, ctx) {
|
|
21288
21412
|
const raw = await safe("/ip firewall filter print detail", ctx);
|
|
@@ -21382,7 +21506,7 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21382
21506
|
arpEntries,
|
|
21383
21507
|
dhcpLeases,
|
|
21384
21508
|
dnsResult,
|
|
21385
|
-
|
|
21509
|
+
dnsSettingsParts,
|
|
21386
21510
|
resourceRaw,
|
|
21387
21511
|
logs,
|
|
21388
21512
|
tunnels
|
|
@@ -21398,13 +21522,17 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21398
21522
|
dims.has("arp_dhcp") ? collectArp(ctx) : Promise.resolve([]),
|
|
21399
21523
|
dims.has("arp_dhcp") ? collectDhcpLeases(ctx) : Promise.resolve([]),
|
|
21400
21524
|
dims.has("dns") && !isIpLike(target) ? safe(`[:resolve ${quoteValue(target)}]`, ctx) : Promise.resolve(undefined),
|
|
21401
|
-
dims.has("dns") ?
|
|
21525
|
+
dims.has("dns") ? Promise.all([
|
|
21526
|
+
safe(":put [/ip dns get servers]", ctx),
|
|
21527
|
+
safe(":put [/ip dns get dynamic-servers]", ctx),
|
|
21528
|
+
safe(":put [/ip dns get allow-remote-requests]", ctx)
|
|
21529
|
+
]) : Promise.resolve(["", "", ""]),
|
|
21402
21530
|
dims.has("resources") ? safe("/system resource print", ctx) : Promise.resolve(""),
|
|
21403
21531
|
dims.has("logs") ? collectLogs(target, ctx) : Promise.resolve([]),
|
|
21404
21532
|
dims.has("vpn") ? collectTunnels(ctx) : Promise.resolve([])
|
|
21405
21533
|
]);
|
|
21406
21534
|
const ping = pingResult ? parsePingSummary(pingResult) ?? undefined : undefined;
|
|
21407
|
-
const
|
|
21535
|
+
const [dnsServersRaw, dnsDynamicRaw, dnsAllowRemoteRaw] = dnsSettingsParts;
|
|
21408
21536
|
const resKv = parseKeyValues(resourceRaw);
|
|
21409
21537
|
const totalMem = parseSize(resKv["total-memory"]) ?? 0;
|
|
21410
21538
|
const freeMem = parseSize(resKv["free-memory"]) ?? 0;
|
|
@@ -21426,8 +21554,8 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21426
21554
|
arpEntries,
|
|
21427
21555
|
dhcpLeases,
|
|
21428
21556
|
dnsResolveResult: dnsResult ?? undefined,
|
|
21429
|
-
dnsServers: [
|
|
21430
|
-
dnsAllowRemote: (
|
|
21557
|
+
dnsServers: [dnsServersRaw.trim(), dnsDynamicRaw.trim()].filter(Boolean).join(",") || "",
|
|
21558
|
+
dnsAllowRemote: dnsAllowRemoteRaw.trim().toLowerCase() === "yes" || dnsAllowRemoteRaw.trim().toLowerCase() === "true",
|
|
21431
21559
|
cpuLoad: parsePercent(resKv["cpu-load"]) ?? 0,
|
|
21432
21560
|
memoryUsedPct: memUsedPct,
|
|
21433
21561
|
uptime: resKv.uptime ?? "",
|
|
@@ -1743,7 +1743,7 @@ function parseKvTokens(chunk) {
|
|
|
1743
1743
|
return out;
|
|
1744
1744
|
}
|
|
1745
1745
|
var INDEX_LINE = /^\s*(\d+)\s+(.*)$/;
|
|
1746
|
-
var LEADING_FLAGS = /^([A-Z]
|
|
1746
|
+
var LEADING_FLAGS = /^([A-Z][A-Za-z]*)(?=\s|$)/;
|
|
1747
1747
|
function unionColumns(rows) {
|
|
1748
1748
|
const seen = new Set;
|
|
1749
1749
|
const cols = [];
|
|
@@ -8243,7 +8243,7 @@ var cache = null;
|
|
|
8243
8243
|
async function gateway() {
|
|
8244
8244
|
if (cache)
|
|
8245
8245
|
return cache;
|
|
8246
|
-
const { moduleCatalog } = await import("./library-
|
|
8246
|
+
const { moduleCatalog } = await import("./library-cs13jqp9.js");
|
|
8247
8247
|
const forIndex = [];
|
|
8248
8248
|
const byName = new Map;
|
|
8249
8249
|
for (const mod of moduleCatalog) {
|
|
@@ -9268,10 +9268,44 @@ function covers(key, aVal, bVal) {
|
|
|
9268
9268
|
return cidrContains(aVal, bVal);
|
|
9269
9269
|
return false;
|
|
9270
9270
|
}
|
|
9271
|
+
var INTERFACE_LIST_PAIRS = [
|
|
9272
|
+
["in-interface-list", "in-interface"],
|
|
9273
|
+
["out-interface-list", "out-interface"]
|
|
9274
|
+
];
|
|
9275
|
+
var _ifaceLists;
|
|
9276
|
+
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
9277
|
+
if (!_ifaceLists)
|
|
9278
|
+
return;
|
|
9279
|
+
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
9280
|
+
if (aKey === listKey && bKey === ifaceKey) {
|
|
9281
|
+
const negated = aVal.startsWith("!");
|
|
9282
|
+
const listName = negated ? aVal.slice(1) : aVal;
|
|
9283
|
+
const members = _ifaceLists.get(listName);
|
|
9284
|
+
if (!members)
|
|
9285
|
+
return;
|
|
9286
|
+
const isMember = members.has(bVal);
|
|
9287
|
+
return negated ? !isMember : isMember;
|
|
9288
|
+
}
|
|
9289
|
+
}
|
|
9290
|
+
return;
|
|
9291
|
+
}
|
|
9271
9292
|
function aCoversB(a, b) {
|
|
9272
9293
|
for (const [k, v] of Object.entries(a.match)) {
|
|
9273
9294
|
const bv = b.match[k];
|
|
9274
|
-
if (bv
|
|
9295
|
+
if (bv !== undefined) {
|
|
9296
|
+
if (!covers(k, v, bv))
|
|
9297
|
+
return false;
|
|
9298
|
+
continue;
|
|
9299
|
+
}
|
|
9300
|
+
let crossCovered = false;
|
|
9301
|
+
for (const [bk, bval] of Object.entries(b.match)) {
|
|
9302
|
+
const result = interfaceListCovers(k, v, bk, bval);
|
|
9303
|
+
if (result === true) {
|
|
9304
|
+
crossCovered = true;
|
|
9305
|
+
break;
|
|
9306
|
+
}
|
|
9307
|
+
}
|
|
9308
|
+
if (!crossCovered)
|
|
9275
9309
|
return false;
|
|
9276
9310
|
}
|
|
9277
9311
|
return true;
|
|
@@ -9448,6 +9482,7 @@ function grade2(score) {
|
|
|
9448
9482
|
return "critical";
|
|
9449
9483
|
}
|
|
9450
9484
|
function auditFirewall(input) {
|
|
9485
|
+
_ifaceLists = input.interfaceLists;
|
|
9451
9486
|
const findings = [];
|
|
9452
9487
|
if (input.filter)
|
|
9453
9488
|
findings.push(...auditFilter(input.filter));
|
|
@@ -9496,6 +9531,25 @@ async function fetchRules(path, ctx) {
|
|
|
9496
9531
|
return [];
|
|
9497
9532
|
return parseRecords(out).rows;
|
|
9498
9533
|
}
|
|
9534
|
+
async function fetchInterfaceListMembers(ctx) {
|
|
9535
|
+
const out = await executeMikrotikCommand("/interface list member print detail", ctx);
|
|
9536
|
+
const members = new Map;
|
|
9537
|
+
if (looksLikeError(out) || isEmpty(out))
|
|
9538
|
+
return members;
|
|
9539
|
+
for (const row of parseRecords(out).rows) {
|
|
9540
|
+
const list = row.list;
|
|
9541
|
+
const iface = row.interface;
|
|
9542
|
+
if (!list || !iface)
|
|
9543
|
+
continue;
|
|
9544
|
+
let set = members.get(list);
|
|
9545
|
+
if (!set) {
|
|
9546
|
+
set = new Set;
|
|
9547
|
+
members.set(list, set);
|
|
9548
|
+
}
|
|
9549
|
+
set.add(iface);
|
|
9550
|
+
}
|
|
9551
|
+
return members;
|
|
9552
|
+
}
|
|
9499
9553
|
var firewallAuditTools = [
|
|
9500
9554
|
defineTool({
|
|
9501
9555
|
name: "firewall_audit",
|
|
@@ -9510,10 +9564,16 @@ var firewallAuditTools = [
|
|
|
9510
9564
|
async handler(a, ctx) {
|
|
9511
9565
|
const device = resolveDeviceName(ctx.device);
|
|
9512
9566
|
ctx.info(`Auditing firewall for '${device}'`);
|
|
9513
|
-
const
|
|
9514
|
-
|
|
9515
|
-
|
|
9516
|
-
|
|
9567
|
+
const [filterRows, natRows, mangleRows, interfaceLists] = await Promise.all([
|
|
9568
|
+
fetchRules("/ip firewall filter", ctx),
|
|
9569
|
+
a.include_nat ? fetchRules("/ip firewall nat", ctx) : Promise.resolve(undefined),
|
|
9570
|
+
a.include_mangle ? fetchRules("/ip firewall mangle", ctx) : Promise.resolve(undefined),
|
|
9571
|
+
fetchInterfaceListMembers(ctx)
|
|
9572
|
+
]);
|
|
9573
|
+
const filter = rulesFromRows(filterRows);
|
|
9574
|
+
const nat = natRows ? rulesFromRows(natRows) : undefined;
|
|
9575
|
+
const mangle = mangleRows ? rulesFromRows(mangleRows) : undefined;
|
|
9576
|
+
const report = auditFirewall({ filter, nat, mangle, interfaceLists });
|
|
9517
9577
|
const structuredContent = {
|
|
9518
9578
|
__mikrotikView: "firewall-audit",
|
|
9519
9579
|
device,
|
|
@@ -9978,6 +10038,22 @@ Management access may be partially restricted \u2014 review immediately.`;
|
|
|
9978
10038
|
// src/tools/firewall-filter.ts
|
|
9979
10039
|
import { z as z28 } from "zod";
|
|
9980
10040
|
var isDigits = (s) => /^\d+$/.test(s);
|
|
10041
|
+
async function resolveFilterRuleId(ruleId, ctx) {
|
|
10042
|
+
if (/^\d+$/.test(ruleId)) {
|
|
10043
|
+
const id = `*${ruleId}`;
|
|
10044
|
+
const byId = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${id}`, ctx);
|
|
10045
|
+
if (byId.trim() !== "0")
|
|
10046
|
+
return id;
|
|
10047
|
+
const idsRaw = await executeMikrotikCommand(`:foreach i in=[/ip firewall filter find] do={:put $i}`, ctx);
|
|
10048
|
+
if (isEmpty(idsRaw))
|
|
10049
|
+
return null;
|
|
10050
|
+
const ids = idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
10051
|
+
const pos = Number.parseInt(ruleId, 10);
|
|
10052
|
+
return pos >= 0 && pos < ids.length ? ids[pos] : null;
|
|
10053
|
+
}
|
|
10054
|
+
const count = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${ruleId}`, ctx);
|
|
10055
|
+
return count.trim() !== "0" ? ruleId : null;
|
|
10056
|
+
}
|
|
9981
10057
|
async function updateFilterRule(a, ctx) {
|
|
9982
10058
|
ctx.info(`Updating firewall filter rule: rule_id=${a.rule_id}`);
|
|
9983
10059
|
const updates = [];
|
|
@@ -10046,7 +10122,9 @@ async function updateFilterRule(a, ctx) {
|
|
|
10046
10122
|
}
|
|
10047
10123
|
if (updates.length === 0)
|
|
10048
10124
|
return "No updates specified.";
|
|
10049
|
-
const id =
|
|
10125
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10126
|
+
if (!id)
|
|
10127
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10050
10128
|
const cmd = `/ip firewall filter set ${id} ${updates.join(" ")}`;
|
|
10051
10129
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
10052
10130
|
if (looksLikeError(result))
|
|
@@ -10190,7 +10268,7 @@ ${details}`;
|
|
|
10190
10268
|
filters.push("invalid=yes");
|
|
10191
10269
|
if (a.dynamic_only)
|
|
10192
10270
|
filters.push("dynamic=yes");
|
|
10193
|
-
const result = await executeMikrotikCommand(`/ip firewall filter print${whereClause(filters)}`, ctx);
|
|
10271
|
+
const result = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
|
|
10194
10272
|
return isEmpty(result) ? "No firewall filter rules found matching the criteria." : `FIREWALL FILTER RULES:
|
|
10195
10273
|
|
|
10196
10274
|
${result}`;
|
|
@@ -10200,15 +10278,17 @@ ${result}`;
|
|
|
10200
10278
|
name: "get_filter_rule",
|
|
10201
10279
|
title: "Get Firewall Filter Rule",
|
|
10202
10280
|
annotations: READ,
|
|
10203
|
-
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. "*
|
|
10281
|
+
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.",
|
|
10204
10282
|
inputSchema: {
|
|
10205
|
-
rule_id: z28.string().describe('Rule
|
|
10283
|
+
rule_id: z28.string().describe('Rule .id e.g. "*1F", or bare position number e.g. "3"')
|
|
10206
10284
|
},
|
|
10207
10285
|
async handler(a, ctx) {
|
|
10208
10286
|
ctx.info(`Getting firewall filter rule details: rule_id=${a.rule_id}`);
|
|
10209
|
-
const id =
|
|
10287
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10288
|
+
if (!id)
|
|
10289
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10210
10290
|
const result = await executeMikrotikCommand(`/ip firewall filter print detail where .id=${id}`, ctx);
|
|
10211
|
-
return isEmpty(result) ? `Firewall filter rule
|
|
10291
|
+
return isEmpty(result) ? `Firewall filter rule '${a.rule_id}' not found.` : `FIREWALL FILTER RULE DETAILS:
|
|
10212
10292
|
|
|
10213
10293
|
${result}`;
|
|
10214
10294
|
}
|
|
@@ -10283,14 +10363,13 @@ ${result}`;
|
|
|
10283
10363
|
inputSchema: { rule_id: z28.string() },
|
|
10284
10364
|
async handler(a, ctx) {
|
|
10285
10365
|
ctx.info(`Removing firewall filter rule: rule_id=${a.rule_id}`);
|
|
10286
|
-
const id =
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
return `Firewall filter rule with ID '${a.rule_id}' not found.`;
|
|
10366
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10367
|
+
if (!id)
|
|
10368
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10290
10369
|
const result = await executeMikrotikCommand(`/ip firewall filter remove ${id}`, ctx);
|
|
10291
10370
|
if (looksLikeError(result))
|
|
10292
10371
|
return `Failed to remove firewall filter rule: ${result}`;
|
|
10293
|
-
return `Firewall filter rule
|
|
10372
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) removed successfully.`;
|
|
10294
10373
|
}
|
|
10295
10374
|
}),
|
|
10296
10375
|
defineTool({
|
|
@@ -10304,14 +10383,13 @@ ${result}`;
|
|
|
10304
10383
|
},
|
|
10305
10384
|
async handler(a, ctx) {
|
|
10306
10385
|
ctx.info(`Moving firewall filter rule: rule_id=${a.rule_id} to position ${a.destination}`);
|
|
10307
|
-
const id =
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
return `Firewall filter rule with ID '${a.rule_id}' not found.`;
|
|
10386
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10387
|
+
if (!id)
|
|
10388
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10311
10389
|
const result = await executeMikrotikCommand(`/ip firewall filter move ${id} destination=${a.destination}`, ctx);
|
|
10312
10390
|
if (looksLikeError(result))
|
|
10313
10391
|
return `Failed to move firewall filter rule: ${result}`;
|
|
10314
|
-
return `Firewall filter rule
|
|
10392
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${a.destination}.`;
|
|
10315
10393
|
}
|
|
10316
10394
|
}),
|
|
10317
10395
|
defineTool({
|
|
@@ -20587,13 +20665,27 @@ function analyzeRootCause(data) {
|
|
|
20587
20665
|
}
|
|
20588
20666
|
}
|
|
20589
20667
|
if (downInterfaces.length === 0 && errorInterfaces.length === 0) {
|
|
20668
|
+
if (data.interfaces.length === 0) {
|
|
20669
|
+
evidence.push({
|
|
20670
|
+
dimension: "interfaces",
|
|
20671
|
+
severity: "warning",
|
|
20672
|
+
summary: "Interface data unavailable \u2014 the device returned no parseable interface records. " + "Run `/interface print detail` manually to verify."
|
|
20673
|
+
});
|
|
20674
|
+
} else {
|
|
20675
|
+
evidence.push({
|
|
20676
|
+
dimension: "interfaces",
|
|
20677
|
+
severity: "ok",
|
|
20678
|
+
summary: `All ${data.interfaces.length} interfaces healthy`
|
|
20679
|
+
});
|
|
20680
|
+
}
|
|
20681
|
+
}
|
|
20682
|
+
if (data.routeCount === 0) {
|
|
20590
20683
|
evidence.push({
|
|
20591
|
-
dimension: "
|
|
20592
|
-
severity: "
|
|
20593
|
-
summary:
|
|
20684
|
+
dimension: "routing",
|
|
20685
|
+
severity: "warning",
|
|
20686
|
+
summary: "Route data unavailable \u2014 the device returned no parseable route records. " + "Run `/ip route print detail` manually to verify."
|
|
20594
20687
|
});
|
|
20595
|
-
}
|
|
20596
|
-
if (!data.defaultRouteExists) {
|
|
20688
|
+
} else if (!data.defaultRouteExists) {
|
|
20597
20689
|
evidence.push({
|
|
20598
20690
|
dimension: "routing",
|
|
20599
20691
|
severity: "critical",
|
|
@@ -20606,7 +20698,7 @@ function analyzeRootCause(data) {
|
|
|
20606
20698
|
summary: `Default route present, ${data.routeCount} total routes`
|
|
20607
20699
|
});
|
|
20608
20700
|
}
|
|
20609
|
-
const ospfDown = data.ospfNeighbors.filter((n) => n.state.toLowerCase() !== "full");
|
|
20701
|
+
const ospfDown = data.ospfNeighbors.filter((n) => (n.id || n.address) && n.state.toLowerCase() !== "full");
|
|
20610
20702
|
for (const n of ospfDown) {
|
|
20611
20703
|
evidence.push({
|
|
20612
20704
|
dimension: "routing",
|
|
@@ -20615,7 +20707,7 @@ function analyzeRootCause(data) {
|
|
|
20615
20707
|
reference: n.id
|
|
20616
20708
|
});
|
|
20617
20709
|
}
|
|
20618
|
-
const bgpDown = data.bgpPeers.filter((p) => !p.state.toLowerCase().includes("established"));
|
|
20710
|
+
const bgpDown = data.bgpPeers.filter((p) => (p.id || p.address) && !p.state.toLowerCase().includes("established"));
|
|
20619
20711
|
for (const p of bgpDown) {
|
|
20620
20712
|
evidence.push({
|
|
20621
20713
|
dimension: "routing",
|
|
@@ -20718,7 +20810,13 @@ function analyzeRootCause(data) {
|
|
|
20718
20810
|
summary: "No DNS servers configured"
|
|
20719
20811
|
});
|
|
20720
20812
|
}
|
|
20721
|
-
if (data.cpuLoad
|
|
20813
|
+
if (data.cpuLoad === 0 && data.memoryUsedPct === 0 && !data.rosVersion && !data.uptime) {
|
|
20814
|
+
evidence.push({
|
|
20815
|
+
dimension: "resources",
|
|
20816
|
+
severity: "warning",
|
|
20817
|
+
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."
|
|
20818
|
+
});
|
|
20819
|
+
} else if (data.cpuLoad > 90) {
|
|
20722
20820
|
evidence.push({
|
|
20723
20821
|
dimension: "resources",
|
|
20724
20822
|
severity: "critical",
|
|
@@ -20737,24 +20835,26 @@ function analyzeRootCause(data) {
|
|
|
20737
20835
|
summary: `CPU load: ${data.cpuLoad}%`
|
|
20738
20836
|
});
|
|
20739
20837
|
}
|
|
20740
|
-
if (data.memoryUsedPct >
|
|
20741
|
-
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
20746
|
-
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
20751
|
-
|
|
20752
|
-
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
20756
|
-
|
|
20757
|
-
|
|
20838
|
+
if (data.rosVersion || data.uptime || data.cpuLoad > 0 || data.memoryUsedPct > 0) {
|
|
20839
|
+
if (data.memoryUsedPct > 90) {
|
|
20840
|
+
evidence.push({
|
|
20841
|
+
dimension: "resources",
|
|
20842
|
+
severity: "critical",
|
|
20843
|
+
summary: `Memory critically low: ${data.memoryUsedPct}% used`
|
|
20844
|
+
});
|
|
20845
|
+
} else if (data.memoryUsedPct > 75) {
|
|
20846
|
+
evidence.push({
|
|
20847
|
+
dimension: "resources",
|
|
20848
|
+
severity: "warning",
|
|
20849
|
+
summary: `Memory pressure: ${data.memoryUsedPct}% used`
|
|
20850
|
+
});
|
|
20851
|
+
} else {
|
|
20852
|
+
evidence.push({
|
|
20853
|
+
dimension: "resources",
|
|
20854
|
+
severity: "ok",
|
|
20855
|
+
summary: `Memory: ${data.memoryUsedPct}% used`
|
|
20856
|
+
});
|
|
20857
|
+
}
|
|
20758
20858
|
}
|
|
20759
20859
|
const errorLogs = data.relevantLogs.filter((l) => l.topics.includes("error") || l.topics.includes("critical") || l.topics.includes("warning"));
|
|
20760
20860
|
const firewallLogs = data.relevantLogs.filter((l) => l.topics.includes("firewall"));
|
|
@@ -20791,14 +20891,24 @@ function analyzeRootCause(data) {
|
|
|
20791
20891
|
summary: "No concerning log entries in the last 10 minutes"
|
|
20792
20892
|
});
|
|
20793
20893
|
}
|
|
20894
|
+
const isServerBinding = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
20794
20895
|
const downTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled);
|
|
20795
20896
|
for (const t of downTunnels) {
|
|
20796
|
-
|
|
20797
|
-
|
|
20798
|
-
|
|
20799
|
-
|
|
20800
|
-
|
|
20801
|
-
|
|
20897
|
+
if (isServerBinding(t)) {
|
|
20898
|
+
evidence.push({
|
|
20899
|
+
dimension: "vpn",
|
|
20900
|
+
severity: "info",
|
|
20901
|
+
summary: `Server binding ${t.name} (${t.type}) is idle \u2014 no active client session`,
|
|
20902
|
+
reference: t.name
|
|
20903
|
+
});
|
|
20904
|
+
} else {
|
|
20905
|
+
evidence.push({
|
|
20906
|
+
dimension: "vpn",
|
|
20907
|
+
severity: "critical",
|
|
20908
|
+
summary: `Tunnel ${t.name} (${t.type}) is down`,
|
|
20909
|
+
reference: t.name
|
|
20910
|
+
});
|
|
20911
|
+
}
|
|
20802
20912
|
}
|
|
20803
20913
|
if (data.tunnelInterfaces.length > 0 && downTunnels.length === 0) {
|
|
20804
20914
|
evidence.push({
|
|
@@ -20808,6 +20918,19 @@ function analyzeRootCause(data) {
|
|
|
20808
20918
|
});
|
|
20809
20919
|
}
|
|
20810
20920
|
correlateRootCauses(data, evidence, rootCauses);
|
|
20921
|
+
if (data.ping && data.ping.lossPct === 0) {
|
|
20922
|
+
const contradicted = new Set([
|
|
20923
|
+
"Missing default route",
|
|
20924
|
+
"Missing source NAT / masquerade",
|
|
20925
|
+
"Interface link failure"
|
|
20926
|
+
]);
|
|
20927
|
+
for (const rc of rootCauses) {
|
|
20928
|
+
if (contradicted.has(rc.cause) && rc.confidence !== "low") {
|
|
20929
|
+
rc.confidence = "low";
|
|
20930
|
+
rc.explanation += " (Note: ping to the target succeeded with 0% loss, which contradicts this hypothesis.)";
|
|
20931
|
+
}
|
|
20932
|
+
}
|
|
20933
|
+
}
|
|
20811
20934
|
rootCauses.sort((a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]);
|
|
20812
20935
|
const dimensionSummary = ALL_DIMENSIONS.map((dim) => {
|
|
20813
20936
|
const dimEvidence = evidence.filter((e) => e.dimension === dim);
|
|
@@ -20946,14 +21069,15 @@ function correlateRootCauses(data, evidence, causes) {
|
|
|
20946
21069
|
});
|
|
20947
21070
|
}
|
|
20948
21071
|
}
|
|
20949
|
-
const
|
|
20950
|
-
|
|
21072
|
+
const isServerTunnel = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
21073
|
+
const realDownTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled && !isServerTunnel(t));
|
|
21074
|
+
if (realDownTunnels.length > 0) {
|
|
20951
21075
|
causes.push({
|
|
20952
21076
|
cause: "VPN/tunnel interface down",
|
|
20953
|
-
explanation: `Tunnel(s) ${
|
|
21077
|
+
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.",
|
|
20954
21078
|
confidence: "high",
|
|
20955
21079
|
evidence: evidence.filter((e) => e.dimension === "vpn" && e.severity === "critical"),
|
|
20956
|
-
fixes:
|
|
21080
|
+
fixes: realDownTunnels.map((t) => `/interface enable [find name="${t.name}"]`),
|
|
20957
21081
|
dimensions: ["vpn"]
|
|
20958
21082
|
});
|
|
20959
21083
|
}
|
|
@@ -21182,7 +21306,7 @@ async function collectRoutes(ctx) {
|
|
|
21182
21306
|
}
|
|
21183
21307
|
async function collectOspfNeighbors(ctx) {
|
|
21184
21308
|
const raw = await safe("/routing ospf neighbor print detail", ctx);
|
|
21185
|
-
if (!raw)
|
|
21309
|
+
if (!raw || isEmpty(raw))
|
|
21186
21310
|
return [];
|
|
21187
21311
|
return parseRecords(raw).rows.map((r) => ({
|
|
21188
21312
|
id: r["neighbor-id"] ?? r.router ?? "",
|
|
@@ -21190,13 +21314,13 @@ async function collectOspfNeighbors(ctx) {
|
|
|
21190
21314
|
state: r.state ?? "",
|
|
21191
21315
|
interface: r.interface ?? "",
|
|
21192
21316
|
uptime: r.uptime
|
|
21193
|
-
}));
|
|
21317
|
+
})).filter((n) => n.id || n.address);
|
|
21194
21318
|
}
|
|
21195
21319
|
async function collectBgpPeers(ctx) {
|
|
21196
21320
|
const raw = await safe("/routing bgp session print detail", ctx);
|
|
21197
|
-
if (!raw) {
|
|
21321
|
+
if (!raw || isEmpty(raw)) {
|
|
21198
21322
|
const raw2 = await safe("/routing bgp peer print detail", ctx);
|
|
21199
|
-
if (!raw2)
|
|
21323
|
+
if (!raw2 || isEmpty(raw2))
|
|
21200
21324
|
return [];
|
|
21201
21325
|
return parseRecords(raw2).rows.map((r) => ({
|
|
21202
21326
|
id: r.name ?? "",
|
|
@@ -21204,7 +21328,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21204
21328
|
state: r.state ?? "",
|
|
21205
21329
|
interface: r.interface ?? "",
|
|
21206
21330
|
uptime: r.uptime
|
|
21207
|
-
}));
|
|
21331
|
+
})).filter((p) => p.id || p.address);
|
|
21208
21332
|
}
|
|
21209
21333
|
return parseRecords(raw).rows.map((r) => ({
|
|
21210
21334
|
id: r.name ?? r["remote.address"] ?? "",
|
|
@@ -21212,7 +21336,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21212
21336
|
state: r.state ?? r.established ?? "",
|
|
21213
21337
|
interface: r.interface ?? "",
|
|
21214
21338
|
uptime: r.uptime
|
|
21215
|
-
}));
|
|
21339
|
+
})).filter((p) => p.id || p.address);
|
|
21216
21340
|
}
|
|
21217
21341
|
async function collectFirewallRules(target, ctx) {
|
|
21218
21342
|
const raw = await safe("/ip firewall filter print detail", ctx);
|
|
@@ -21312,7 +21436,7 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21312
21436
|
arpEntries,
|
|
21313
21437
|
dhcpLeases,
|
|
21314
21438
|
dnsResult,
|
|
21315
|
-
|
|
21439
|
+
dnsSettingsParts,
|
|
21316
21440
|
resourceRaw,
|
|
21317
21441
|
logs,
|
|
21318
21442
|
tunnels
|
|
@@ -21328,13 +21452,17 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21328
21452
|
dims.has("arp_dhcp") ? collectArp(ctx) : Promise.resolve([]),
|
|
21329
21453
|
dims.has("arp_dhcp") ? collectDhcpLeases(ctx) : Promise.resolve([]),
|
|
21330
21454
|
dims.has("dns") && !isIpLike(target) ? safe(`[:resolve ${quoteValue(target)}]`, ctx) : Promise.resolve(undefined),
|
|
21331
|
-
dims.has("dns") ?
|
|
21455
|
+
dims.has("dns") ? Promise.all([
|
|
21456
|
+
safe(":put [/ip dns get servers]", ctx),
|
|
21457
|
+
safe(":put [/ip dns get dynamic-servers]", ctx),
|
|
21458
|
+
safe(":put [/ip dns get allow-remote-requests]", ctx)
|
|
21459
|
+
]) : Promise.resolve(["", "", ""]),
|
|
21332
21460
|
dims.has("resources") ? safe("/system resource print", ctx) : Promise.resolve(""),
|
|
21333
21461
|
dims.has("logs") ? collectLogs(target, ctx) : Promise.resolve([]),
|
|
21334
21462
|
dims.has("vpn") ? collectTunnels(ctx) : Promise.resolve([])
|
|
21335
21463
|
]);
|
|
21336
21464
|
const ping = pingResult ? parsePingSummary(pingResult) ?? undefined : undefined;
|
|
21337
|
-
const
|
|
21465
|
+
const [dnsServersRaw, dnsDynamicRaw, dnsAllowRemoteRaw] = dnsSettingsParts;
|
|
21338
21466
|
const resKv = parseKeyValues(resourceRaw);
|
|
21339
21467
|
const totalMem = parseSize(resKv["total-memory"]) ?? 0;
|
|
21340
21468
|
const freeMem = parseSize(resKv["free-memory"]) ?? 0;
|
|
@@ -21356,8 +21484,8 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21356
21484
|
arpEntries,
|
|
21357
21485
|
dhcpLeases,
|
|
21358
21486
|
dnsResolveResult: dnsResult ?? undefined,
|
|
21359
|
-
dnsServers: [
|
|
21360
|
-
dnsAllowRemote: (
|
|
21487
|
+
dnsServers: [dnsServersRaw.trim(), dnsDynamicRaw.trim()].filter(Boolean).join(",") || "",
|
|
21488
|
+
dnsAllowRemote: dnsAllowRemoteRaw.trim().toLowerCase() === "yes" || dnsAllowRemoteRaw.trim().toLowerCase() === "true",
|
|
21361
21489
|
cpuLoad: parsePercent(resKv["cpu-load"]) ?? 0,
|
|
21362
21490
|
memoryUsedPct: memUsedPct,
|
|
21363
21491
|
uptime: resKv.uptime ?? "",
|
package/package.json
CHANGED