@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
|
@@ -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(),
|
|
@@ -581,13 +582,17 @@ function setConfig(cfg) {
|
|
|
581
582
|
function getConfig() {
|
|
582
583
|
return active;
|
|
583
584
|
}
|
|
585
|
+
function isEnabled(dc) {
|
|
586
|
+
return !dc.disabled;
|
|
587
|
+
}
|
|
584
588
|
function listDevices() {
|
|
585
|
-
|
|
589
|
+
const names = Object.entries(active.devices).filter(([, dc]) => isEnabled(dc)).map(([k]) => k);
|
|
590
|
+
return { names, default: active.defaultDevice };
|
|
586
591
|
}
|
|
587
592
|
function deviceKeyForLabel(name) {
|
|
588
593
|
const target = name.trim().toLowerCase();
|
|
589
594
|
for (const [key, dc] of Object.entries(active.devices)) {
|
|
590
|
-
if (dc.description && dc.description.trim().toLowerCase() === target)
|
|
595
|
+
if (isEnabled(dc) && dc.description && dc.description.trim().toLowerCase() === target)
|
|
591
596
|
return key;
|
|
592
597
|
}
|
|
593
598
|
return;
|
|
@@ -596,6 +601,8 @@ function deviceLabels() {
|
|
|
596
601
|
const seen = new Set;
|
|
597
602
|
const out = [];
|
|
598
603
|
for (const [key, dc] of Object.entries(active.devices)) {
|
|
604
|
+
if (!isEnabled(dc))
|
|
605
|
+
continue;
|
|
599
606
|
const label = dc.description?.trim();
|
|
600
607
|
if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
|
|
601
608
|
seen.add(label);
|
|
@@ -606,13 +613,17 @@ function deviceLabels() {
|
|
|
606
613
|
}
|
|
607
614
|
function resolveDeviceName(name) {
|
|
608
615
|
if (name) {
|
|
609
|
-
if (name in active.devices)
|
|
616
|
+
if (name in active.devices && isEnabled(active.devices[name]))
|
|
610
617
|
return name;
|
|
611
618
|
const byLabel = deviceKeyForLabel(name);
|
|
612
619
|
if (byLabel)
|
|
613
620
|
return byLabel;
|
|
614
621
|
}
|
|
615
|
-
|
|
622
|
+
if (active.defaultDevice in active.devices && isEnabled(active.devices[active.defaultDevice])) {
|
|
623
|
+
return active.defaultDevice;
|
|
624
|
+
}
|
|
625
|
+
const firstEnabled = Object.entries(active.devices).find(([, dc]) => isEnabled(dc));
|
|
626
|
+
return firstEnabled ? firstEnabled[0] : active.defaultDevice;
|
|
616
627
|
}
|
|
617
628
|
function deviceTarget(dc) {
|
|
618
629
|
if (!dc)
|
|
@@ -620,7 +631,7 @@ function deviceTarget(dc) {
|
|
|
620
631
|
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
621
632
|
}
|
|
622
633
|
function deviceDirectory() {
|
|
623
|
-
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
634
|
+
return Object.entries(active.devices).filter(([, dc]) => isEnabled(dc)).map(([key, dc]) => ({
|
|
624
635
|
key,
|
|
625
636
|
label: dc.description?.trim() || undefined,
|
|
626
637
|
target: deviceTarget(dc),
|
|
@@ -640,6 +651,9 @@ function getDevice(name) {
|
|
|
640
651
|
const dc = active.devices[key];
|
|
641
652
|
if (!dc)
|
|
642
653
|
throw new Error(`No device configuration available for '${key}'.`);
|
|
654
|
+
if (dc.disabled) {
|
|
655
|
+
throw new Error(`Device '${key}' is disabled. Enable it from the dashboard or config file.`);
|
|
656
|
+
}
|
|
643
657
|
return dc;
|
|
644
658
|
}
|
|
645
659
|
|
|
@@ -1743,7 +1757,7 @@ function parseKvTokens(chunk) {
|
|
|
1743
1757
|
return out;
|
|
1744
1758
|
}
|
|
1745
1759
|
var INDEX_LINE = /^\s*(\d+)\s+(.*)$/;
|
|
1746
|
-
var LEADING_FLAGS = /^([A-Z]
|
|
1760
|
+
var LEADING_FLAGS = /^([A-Z][A-Za-z]*)(?=\s|$)/;
|
|
1747
1761
|
function unionColumns(rows) {
|
|
1748
1762
|
const seen = new Set;
|
|
1749
1763
|
const cols = [];
|
|
@@ -8243,7 +8257,7 @@ var cache = null;
|
|
|
8243
8257
|
async function gateway() {
|
|
8244
8258
|
if (cache)
|
|
8245
8259
|
return cache;
|
|
8246
|
-
const { moduleCatalog } = await import("./library-
|
|
8260
|
+
const { moduleCatalog } = await import("./library-jve65pzw.js");
|
|
8247
8261
|
const forIndex = [];
|
|
8248
8262
|
const byName = new Map;
|
|
8249
8263
|
for (const mod of moduleCatalog) {
|
|
@@ -9268,10 +9282,44 @@ function covers(key, aVal, bVal) {
|
|
|
9268
9282
|
return cidrContains(aVal, bVal);
|
|
9269
9283
|
return false;
|
|
9270
9284
|
}
|
|
9285
|
+
var INTERFACE_LIST_PAIRS = [
|
|
9286
|
+
["in-interface-list", "in-interface"],
|
|
9287
|
+
["out-interface-list", "out-interface"]
|
|
9288
|
+
];
|
|
9289
|
+
var _ifaceLists;
|
|
9290
|
+
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
9291
|
+
if (!_ifaceLists)
|
|
9292
|
+
return;
|
|
9293
|
+
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
9294
|
+
if (aKey === listKey && bKey === ifaceKey) {
|
|
9295
|
+
const negated = aVal.startsWith("!");
|
|
9296
|
+
const listName = negated ? aVal.slice(1) : aVal;
|
|
9297
|
+
const members = _ifaceLists.get(listName);
|
|
9298
|
+
if (!members)
|
|
9299
|
+
return;
|
|
9300
|
+
const isMember = members.has(bVal);
|
|
9301
|
+
return negated ? !isMember : isMember;
|
|
9302
|
+
}
|
|
9303
|
+
}
|
|
9304
|
+
return;
|
|
9305
|
+
}
|
|
9271
9306
|
function aCoversB(a, b) {
|
|
9272
9307
|
for (const [k, v] of Object.entries(a.match)) {
|
|
9273
9308
|
const bv = b.match[k];
|
|
9274
|
-
if (bv
|
|
9309
|
+
if (bv !== undefined) {
|
|
9310
|
+
if (!covers(k, v, bv))
|
|
9311
|
+
return false;
|
|
9312
|
+
continue;
|
|
9313
|
+
}
|
|
9314
|
+
let crossCovered = false;
|
|
9315
|
+
for (const [bk, bval] of Object.entries(b.match)) {
|
|
9316
|
+
const result = interfaceListCovers(k, v, bk, bval);
|
|
9317
|
+
if (result === true) {
|
|
9318
|
+
crossCovered = true;
|
|
9319
|
+
break;
|
|
9320
|
+
}
|
|
9321
|
+
}
|
|
9322
|
+
if (!crossCovered)
|
|
9275
9323
|
return false;
|
|
9276
9324
|
}
|
|
9277
9325
|
return true;
|
|
@@ -9448,6 +9496,7 @@ function grade2(score) {
|
|
|
9448
9496
|
return "critical";
|
|
9449
9497
|
}
|
|
9450
9498
|
function auditFirewall(input) {
|
|
9499
|
+
_ifaceLists = input.interfaceLists;
|
|
9451
9500
|
const findings = [];
|
|
9452
9501
|
if (input.filter)
|
|
9453
9502
|
findings.push(...auditFilter(input.filter));
|
|
@@ -9496,6 +9545,25 @@ async function fetchRules(path, ctx) {
|
|
|
9496
9545
|
return [];
|
|
9497
9546
|
return parseRecords(out).rows;
|
|
9498
9547
|
}
|
|
9548
|
+
async function fetchInterfaceListMembers(ctx) {
|
|
9549
|
+
const out = await executeMikrotikCommand("/interface list member print detail", ctx);
|
|
9550
|
+
const members = new Map;
|
|
9551
|
+
if (looksLikeError(out) || isEmpty(out))
|
|
9552
|
+
return members;
|
|
9553
|
+
for (const row of parseRecords(out).rows) {
|
|
9554
|
+
const list = row.list;
|
|
9555
|
+
const iface = row.interface;
|
|
9556
|
+
if (!list || !iface)
|
|
9557
|
+
continue;
|
|
9558
|
+
let set = members.get(list);
|
|
9559
|
+
if (!set) {
|
|
9560
|
+
set = new Set;
|
|
9561
|
+
members.set(list, set);
|
|
9562
|
+
}
|
|
9563
|
+
set.add(iface);
|
|
9564
|
+
}
|
|
9565
|
+
return members;
|
|
9566
|
+
}
|
|
9499
9567
|
var firewallAuditTools = [
|
|
9500
9568
|
defineTool({
|
|
9501
9569
|
name: "firewall_audit",
|
|
@@ -9510,10 +9578,16 @@ var firewallAuditTools = [
|
|
|
9510
9578
|
async handler(a, ctx) {
|
|
9511
9579
|
const device = resolveDeviceName(ctx.device);
|
|
9512
9580
|
ctx.info(`Auditing firewall for '${device}'`);
|
|
9513
|
-
const
|
|
9514
|
-
|
|
9515
|
-
|
|
9516
|
-
|
|
9581
|
+
const [filterRows, natRows, mangleRows, interfaceLists] = await Promise.all([
|
|
9582
|
+
fetchRules("/ip firewall filter", ctx),
|
|
9583
|
+
a.include_nat ? fetchRules("/ip firewall nat", ctx) : Promise.resolve(undefined),
|
|
9584
|
+
a.include_mangle ? fetchRules("/ip firewall mangle", ctx) : Promise.resolve(undefined),
|
|
9585
|
+
fetchInterfaceListMembers(ctx)
|
|
9586
|
+
]);
|
|
9587
|
+
const filter = rulesFromRows(filterRows);
|
|
9588
|
+
const nat = natRows ? rulesFromRows(natRows) : undefined;
|
|
9589
|
+
const mangle = mangleRows ? rulesFromRows(mangleRows) : undefined;
|
|
9590
|
+
const report = auditFirewall({ filter, nat, mangle, interfaceLists });
|
|
9517
9591
|
const structuredContent = {
|
|
9518
9592
|
__mikrotikView: "firewall-audit",
|
|
9519
9593
|
device,
|
|
@@ -9978,6 +10052,22 @@ Management access may be partially restricted \u2014 review immediately.`;
|
|
|
9978
10052
|
// src/tools/firewall-filter.ts
|
|
9979
10053
|
import { z as z28 } from "zod";
|
|
9980
10054
|
var isDigits = (s) => /^\d+$/.test(s);
|
|
10055
|
+
async function resolveFilterRuleId(ruleId, ctx) {
|
|
10056
|
+
if (/^\d+$/.test(ruleId)) {
|
|
10057
|
+
const id = `*${ruleId}`;
|
|
10058
|
+
const byId = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${id}`, ctx);
|
|
10059
|
+
if (byId.trim() !== "0")
|
|
10060
|
+
return id;
|
|
10061
|
+
const idsRaw = await executeMikrotikCommand(`:foreach i in=[/ip firewall filter find] do={:put $i}`, ctx);
|
|
10062
|
+
if (isEmpty(idsRaw))
|
|
10063
|
+
return null;
|
|
10064
|
+
const ids = idsRaw.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
10065
|
+
const pos = Number.parseInt(ruleId, 10);
|
|
10066
|
+
return pos >= 0 && pos < ids.length ? ids[pos] : null;
|
|
10067
|
+
}
|
|
10068
|
+
const count = await executeMikrotikCommand(`/ip firewall filter print count-only where .id=${ruleId}`, ctx);
|
|
10069
|
+
return count.trim() !== "0" ? ruleId : null;
|
|
10070
|
+
}
|
|
9981
10071
|
async function updateFilterRule(a, ctx) {
|
|
9982
10072
|
ctx.info(`Updating firewall filter rule: rule_id=${a.rule_id}`);
|
|
9983
10073
|
const updates = [];
|
|
@@ -10046,7 +10136,9 @@ async function updateFilterRule(a, ctx) {
|
|
|
10046
10136
|
}
|
|
10047
10137
|
if (updates.length === 0)
|
|
10048
10138
|
return "No updates specified.";
|
|
10049
|
-
const id =
|
|
10139
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10140
|
+
if (!id)
|
|
10141
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10050
10142
|
const cmd = `/ip firewall filter set ${id} ${updates.join(" ")}`;
|
|
10051
10143
|
const result = await executeMikrotikCommand(cmd, ctx);
|
|
10052
10144
|
if (looksLikeError(result))
|
|
@@ -10190,7 +10282,7 @@ ${details}`;
|
|
|
10190
10282
|
filters.push("invalid=yes");
|
|
10191
10283
|
if (a.dynamic_only)
|
|
10192
10284
|
filters.push("dynamic=yes");
|
|
10193
|
-
const result = await executeMikrotikCommand(`/ip firewall filter print${whereClause(filters)}`, ctx);
|
|
10285
|
+
const result = await executeMikrotikCommand(`/ip firewall filter print detail${whereClause(filters)}`, ctx);
|
|
10194
10286
|
return isEmpty(result) ? "No firewall filter rules found matching the criteria." : `FIREWALL FILTER RULES:
|
|
10195
10287
|
|
|
10196
10288
|
${result}`;
|
|
@@ -10200,15 +10292,17 @@ ${result}`;
|
|
|
10200
10292
|
name: "get_filter_rule",
|
|
10201
10293
|
title: "Get Firewall Filter Rule",
|
|
10202
10294
|
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. "*
|
|
10295
|
+
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
10296
|
inputSchema: {
|
|
10205
|
-
rule_id: z28.string().describe('Rule
|
|
10297
|
+
rule_id: z28.string().describe('Rule .id e.g. "*1F", or bare position number e.g. "3"')
|
|
10206
10298
|
},
|
|
10207
10299
|
async handler(a, ctx) {
|
|
10208
10300
|
ctx.info(`Getting firewall filter rule details: rule_id=${a.rule_id}`);
|
|
10209
|
-
const id =
|
|
10301
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10302
|
+
if (!id)
|
|
10303
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10210
10304
|
const result = await executeMikrotikCommand(`/ip firewall filter print detail where .id=${id}`, ctx);
|
|
10211
|
-
return isEmpty(result) ? `Firewall filter rule
|
|
10305
|
+
return isEmpty(result) ? `Firewall filter rule '${a.rule_id}' not found.` : `FIREWALL FILTER RULE DETAILS:
|
|
10212
10306
|
|
|
10213
10307
|
${result}`;
|
|
10214
10308
|
}
|
|
@@ -10283,14 +10377,13 @@ ${result}`;
|
|
|
10283
10377
|
inputSchema: { rule_id: z28.string() },
|
|
10284
10378
|
async handler(a, ctx) {
|
|
10285
10379
|
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.`;
|
|
10380
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10381
|
+
if (!id)
|
|
10382
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10290
10383
|
const result = await executeMikrotikCommand(`/ip firewall filter remove ${id}`, ctx);
|
|
10291
10384
|
if (looksLikeError(result))
|
|
10292
10385
|
return `Failed to remove firewall filter rule: ${result}`;
|
|
10293
|
-
return `Firewall filter rule
|
|
10386
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) removed successfully.`;
|
|
10294
10387
|
}
|
|
10295
10388
|
}),
|
|
10296
10389
|
defineTool({
|
|
@@ -10304,14 +10397,13 @@ ${result}`;
|
|
|
10304
10397
|
},
|
|
10305
10398
|
async handler(a, ctx) {
|
|
10306
10399
|
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.`;
|
|
10400
|
+
const id = await resolveFilterRuleId(a.rule_id, ctx);
|
|
10401
|
+
if (!id)
|
|
10402
|
+
return `Firewall filter rule '${a.rule_id}' not found.`;
|
|
10311
10403
|
const result = await executeMikrotikCommand(`/ip firewall filter move ${id} destination=${a.destination}`, ctx);
|
|
10312
10404
|
if (looksLikeError(result))
|
|
10313
10405
|
return `Failed to move firewall filter rule: ${result}`;
|
|
10314
|
-
return `Firewall filter rule
|
|
10406
|
+
return `Firewall filter rule '${a.rule_id}' (${id}) moved to position ${a.destination}.`;
|
|
10315
10407
|
}
|
|
10316
10408
|
}),
|
|
10317
10409
|
defineTool({
|
|
@@ -20587,13 +20679,27 @@ function analyzeRootCause(data) {
|
|
|
20587
20679
|
}
|
|
20588
20680
|
}
|
|
20589
20681
|
if (downInterfaces.length === 0 && errorInterfaces.length === 0) {
|
|
20682
|
+
if (data.interfaces.length === 0) {
|
|
20683
|
+
evidence.push({
|
|
20684
|
+
dimension: "interfaces",
|
|
20685
|
+
severity: "warning",
|
|
20686
|
+
summary: "Interface data unavailable \u2014 the device returned no parseable interface records. " + "Run `/interface print detail` manually to verify."
|
|
20687
|
+
});
|
|
20688
|
+
} else {
|
|
20689
|
+
evidence.push({
|
|
20690
|
+
dimension: "interfaces",
|
|
20691
|
+
severity: "ok",
|
|
20692
|
+
summary: `All ${data.interfaces.length} interfaces healthy`
|
|
20693
|
+
});
|
|
20694
|
+
}
|
|
20695
|
+
}
|
|
20696
|
+
if (data.routeCount === 0) {
|
|
20590
20697
|
evidence.push({
|
|
20591
|
-
dimension: "
|
|
20592
|
-
severity: "
|
|
20593
|
-
summary:
|
|
20698
|
+
dimension: "routing",
|
|
20699
|
+
severity: "warning",
|
|
20700
|
+
summary: "Route data unavailable \u2014 the device returned no parseable route records. " + "Run `/ip route print detail` manually to verify."
|
|
20594
20701
|
});
|
|
20595
|
-
}
|
|
20596
|
-
if (!data.defaultRouteExists) {
|
|
20702
|
+
} else if (!data.defaultRouteExists) {
|
|
20597
20703
|
evidence.push({
|
|
20598
20704
|
dimension: "routing",
|
|
20599
20705
|
severity: "critical",
|
|
@@ -20606,7 +20712,7 @@ function analyzeRootCause(data) {
|
|
|
20606
20712
|
summary: `Default route present, ${data.routeCount} total routes`
|
|
20607
20713
|
});
|
|
20608
20714
|
}
|
|
20609
|
-
const ospfDown = data.ospfNeighbors.filter((n) => n.state.toLowerCase() !== "full");
|
|
20715
|
+
const ospfDown = data.ospfNeighbors.filter((n) => (n.id || n.address) && n.state.toLowerCase() !== "full");
|
|
20610
20716
|
for (const n of ospfDown) {
|
|
20611
20717
|
evidence.push({
|
|
20612
20718
|
dimension: "routing",
|
|
@@ -20615,7 +20721,7 @@ function analyzeRootCause(data) {
|
|
|
20615
20721
|
reference: n.id
|
|
20616
20722
|
});
|
|
20617
20723
|
}
|
|
20618
|
-
const bgpDown = data.bgpPeers.filter((p) => !p.state.toLowerCase().includes("established"));
|
|
20724
|
+
const bgpDown = data.bgpPeers.filter((p) => (p.id || p.address) && !p.state.toLowerCase().includes("established"));
|
|
20619
20725
|
for (const p of bgpDown) {
|
|
20620
20726
|
evidence.push({
|
|
20621
20727
|
dimension: "routing",
|
|
@@ -20718,7 +20824,13 @@ function analyzeRootCause(data) {
|
|
|
20718
20824
|
summary: "No DNS servers configured"
|
|
20719
20825
|
});
|
|
20720
20826
|
}
|
|
20721
|
-
if (data.cpuLoad
|
|
20827
|
+
if (data.cpuLoad === 0 && data.memoryUsedPct === 0 && !data.rosVersion && !data.uptime) {
|
|
20828
|
+
evidence.push({
|
|
20829
|
+
dimension: "resources",
|
|
20830
|
+
severity: "warning",
|
|
20831
|
+
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."
|
|
20832
|
+
});
|
|
20833
|
+
} else if (data.cpuLoad > 90) {
|
|
20722
20834
|
evidence.push({
|
|
20723
20835
|
dimension: "resources",
|
|
20724
20836
|
severity: "critical",
|
|
@@ -20737,24 +20849,26 @@ function analyzeRootCause(data) {
|
|
|
20737
20849
|
summary: `CPU load: ${data.cpuLoad}%`
|
|
20738
20850
|
});
|
|
20739
20851
|
}
|
|
20740
|
-
if (data.memoryUsedPct >
|
|
20741
|
-
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
20746
|
-
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
20751
|
-
|
|
20752
|
-
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
20756
|
-
|
|
20757
|
-
|
|
20852
|
+
if (data.rosVersion || data.uptime || data.cpuLoad > 0 || data.memoryUsedPct > 0) {
|
|
20853
|
+
if (data.memoryUsedPct > 90) {
|
|
20854
|
+
evidence.push({
|
|
20855
|
+
dimension: "resources",
|
|
20856
|
+
severity: "critical",
|
|
20857
|
+
summary: `Memory critically low: ${data.memoryUsedPct}% used`
|
|
20858
|
+
});
|
|
20859
|
+
} else if (data.memoryUsedPct > 75) {
|
|
20860
|
+
evidence.push({
|
|
20861
|
+
dimension: "resources",
|
|
20862
|
+
severity: "warning",
|
|
20863
|
+
summary: `Memory pressure: ${data.memoryUsedPct}% used`
|
|
20864
|
+
});
|
|
20865
|
+
} else {
|
|
20866
|
+
evidence.push({
|
|
20867
|
+
dimension: "resources",
|
|
20868
|
+
severity: "ok",
|
|
20869
|
+
summary: `Memory: ${data.memoryUsedPct}% used`
|
|
20870
|
+
});
|
|
20871
|
+
}
|
|
20758
20872
|
}
|
|
20759
20873
|
const errorLogs = data.relevantLogs.filter((l) => l.topics.includes("error") || l.topics.includes("critical") || l.topics.includes("warning"));
|
|
20760
20874
|
const firewallLogs = data.relevantLogs.filter((l) => l.topics.includes("firewall"));
|
|
@@ -20791,14 +20905,24 @@ function analyzeRootCause(data) {
|
|
|
20791
20905
|
summary: "No concerning log entries in the last 10 minutes"
|
|
20792
20906
|
});
|
|
20793
20907
|
}
|
|
20908
|
+
const isServerBinding = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
20794
20909
|
const downTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled);
|
|
20795
20910
|
for (const t of downTunnels) {
|
|
20796
|
-
|
|
20797
|
-
|
|
20798
|
-
|
|
20799
|
-
|
|
20800
|
-
|
|
20801
|
-
|
|
20911
|
+
if (isServerBinding(t)) {
|
|
20912
|
+
evidence.push({
|
|
20913
|
+
dimension: "vpn",
|
|
20914
|
+
severity: "info",
|
|
20915
|
+
summary: `Server binding ${t.name} (${t.type}) is idle \u2014 no active client session`,
|
|
20916
|
+
reference: t.name
|
|
20917
|
+
});
|
|
20918
|
+
} else {
|
|
20919
|
+
evidence.push({
|
|
20920
|
+
dimension: "vpn",
|
|
20921
|
+
severity: "critical",
|
|
20922
|
+
summary: `Tunnel ${t.name} (${t.type}) is down`,
|
|
20923
|
+
reference: t.name
|
|
20924
|
+
});
|
|
20925
|
+
}
|
|
20802
20926
|
}
|
|
20803
20927
|
if (data.tunnelInterfaces.length > 0 && downTunnels.length === 0) {
|
|
20804
20928
|
evidence.push({
|
|
@@ -20808,6 +20932,19 @@ function analyzeRootCause(data) {
|
|
|
20808
20932
|
});
|
|
20809
20933
|
}
|
|
20810
20934
|
correlateRootCauses(data, evidence, rootCauses);
|
|
20935
|
+
if (data.ping && data.ping.lossPct === 0) {
|
|
20936
|
+
const contradicted = new Set([
|
|
20937
|
+
"Missing default route",
|
|
20938
|
+
"Missing source NAT / masquerade",
|
|
20939
|
+
"Interface link failure"
|
|
20940
|
+
]);
|
|
20941
|
+
for (const rc of rootCauses) {
|
|
20942
|
+
if (contradicted.has(rc.cause) && rc.confidence !== "low") {
|
|
20943
|
+
rc.confidence = "low";
|
|
20944
|
+
rc.explanation += " (Note: ping to the target succeeded with 0% loss, which contradicts this hypothesis.)";
|
|
20945
|
+
}
|
|
20946
|
+
}
|
|
20947
|
+
}
|
|
20811
20948
|
rootCauses.sort((a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]);
|
|
20812
20949
|
const dimensionSummary = ALL_DIMENSIONS.map((dim) => {
|
|
20813
20950
|
const dimEvidence = evidence.filter((e) => e.dimension === dim);
|
|
@@ -20946,14 +21083,15 @@ function correlateRootCauses(data, evidence, causes) {
|
|
|
20946
21083
|
});
|
|
20947
21084
|
}
|
|
20948
21085
|
}
|
|
20949
|
-
const
|
|
20950
|
-
|
|
21086
|
+
const isServerTunnel = (t) => /-(in|server)$/.test(t.type) || t.name.startsWith("<");
|
|
21087
|
+
const realDownTunnels = data.tunnelInterfaces.filter((t) => !t.running && !t.disabled && !isServerTunnel(t));
|
|
21088
|
+
if (realDownTunnels.length > 0) {
|
|
20951
21089
|
causes.push({
|
|
20952
21090
|
cause: "VPN/tunnel interface down",
|
|
20953
|
-
explanation: `Tunnel(s) ${
|
|
21091
|
+
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
21092
|
confidence: "high",
|
|
20955
21093
|
evidence: evidence.filter((e) => e.dimension === "vpn" && e.severity === "critical"),
|
|
20956
|
-
fixes:
|
|
21094
|
+
fixes: realDownTunnels.map((t) => `/interface enable [find name="${t.name}"]`),
|
|
20957
21095
|
dimensions: ["vpn"]
|
|
20958
21096
|
});
|
|
20959
21097
|
}
|
|
@@ -21182,7 +21320,7 @@ async function collectRoutes(ctx) {
|
|
|
21182
21320
|
}
|
|
21183
21321
|
async function collectOspfNeighbors(ctx) {
|
|
21184
21322
|
const raw = await safe("/routing ospf neighbor print detail", ctx);
|
|
21185
|
-
if (!raw)
|
|
21323
|
+
if (!raw || isEmpty(raw))
|
|
21186
21324
|
return [];
|
|
21187
21325
|
return parseRecords(raw).rows.map((r) => ({
|
|
21188
21326
|
id: r["neighbor-id"] ?? r.router ?? "",
|
|
@@ -21190,13 +21328,13 @@ async function collectOspfNeighbors(ctx) {
|
|
|
21190
21328
|
state: r.state ?? "",
|
|
21191
21329
|
interface: r.interface ?? "",
|
|
21192
21330
|
uptime: r.uptime
|
|
21193
|
-
}));
|
|
21331
|
+
})).filter((n) => n.id || n.address);
|
|
21194
21332
|
}
|
|
21195
21333
|
async function collectBgpPeers(ctx) {
|
|
21196
21334
|
const raw = await safe("/routing bgp session print detail", ctx);
|
|
21197
|
-
if (!raw) {
|
|
21335
|
+
if (!raw || isEmpty(raw)) {
|
|
21198
21336
|
const raw2 = await safe("/routing bgp peer print detail", ctx);
|
|
21199
|
-
if (!raw2)
|
|
21337
|
+
if (!raw2 || isEmpty(raw2))
|
|
21200
21338
|
return [];
|
|
21201
21339
|
return parseRecords(raw2).rows.map((r) => ({
|
|
21202
21340
|
id: r.name ?? "",
|
|
@@ -21204,7 +21342,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21204
21342
|
state: r.state ?? "",
|
|
21205
21343
|
interface: r.interface ?? "",
|
|
21206
21344
|
uptime: r.uptime
|
|
21207
|
-
}));
|
|
21345
|
+
})).filter((p) => p.id || p.address);
|
|
21208
21346
|
}
|
|
21209
21347
|
return parseRecords(raw).rows.map((r) => ({
|
|
21210
21348
|
id: r.name ?? r["remote.address"] ?? "",
|
|
@@ -21212,7 +21350,7 @@ async function collectBgpPeers(ctx) {
|
|
|
21212
21350
|
state: r.state ?? r.established ?? "",
|
|
21213
21351
|
interface: r.interface ?? "",
|
|
21214
21352
|
uptime: r.uptime
|
|
21215
|
-
}));
|
|
21353
|
+
})).filter((p) => p.id || p.address);
|
|
21216
21354
|
}
|
|
21217
21355
|
async function collectFirewallRules(target, ctx) {
|
|
21218
21356
|
const raw = await safe("/ip firewall filter print detail", ctx);
|
|
@@ -21312,7 +21450,7 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21312
21450
|
arpEntries,
|
|
21313
21451
|
dhcpLeases,
|
|
21314
21452
|
dnsResult,
|
|
21315
|
-
|
|
21453
|
+
dnsSettingsParts,
|
|
21316
21454
|
resourceRaw,
|
|
21317
21455
|
logs,
|
|
21318
21456
|
tunnels
|
|
@@ -21328,13 +21466,17 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21328
21466
|
dims.has("arp_dhcp") ? collectArp(ctx) : Promise.resolve([]),
|
|
21329
21467
|
dims.has("arp_dhcp") ? collectDhcpLeases(ctx) : Promise.resolve([]),
|
|
21330
21468
|
dims.has("dns") && !isIpLike(target) ? safe(`[:resolve ${quoteValue(target)}]`, ctx) : Promise.resolve(undefined),
|
|
21331
|
-
dims.has("dns") ?
|
|
21469
|
+
dims.has("dns") ? Promise.all([
|
|
21470
|
+
safe(":put [/ip dns get servers]", ctx),
|
|
21471
|
+
safe(":put [/ip dns get dynamic-servers]", ctx),
|
|
21472
|
+
safe(":put [/ip dns get allow-remote-requests]", ctx)
|
|
21473
|
+
]) : Promise.resolve(["", "", ""]),
|
|
21332
21474
|
dims.has("resources") ? safe("/system resource print", ctx) : Promise.resolve(""),
|
|
21333
21475
|
dims.has("logs") ? collectLogs(target, ctx) : Promise.resolve([]),
|
|
21334
21476
|
dims.has("vpn") ? collectTunnels(ctx) : Promise.resolve([])
|
|
21335
21477
|
]);
|
|
21336
21478
|
const ping = pingResult ? parsePingSummary(pingResult) ?? undefined : undefined;
|
|
21337
|
-
const
|
|
21479
|
+
const [dnsServersRaw, dnsDynamicRaw, dnsAllowRemoteRaw] = dnsSettingsParts;
|
|
21338
21480
|
const resKv = parseKeyValues(resourceRaw);
|
|
21339
21481
|
const totalMem = parseSize(resKv["total-memory"]) ?? 0;
|
|
21340
21482
|
const freeMem = parseSize(resKv["free-memory"]) ?? 0;
|
|
@@ -21356,8 +21498,8 @@ async function collectDiagnosticData(target, ctx, dimensions) {
|
|
|
21356
21498
|
arpEntries,
|
|
21357
21499
|
dhcpLeases,
|
|
21358
21500
|
dnsResolveResult: dnsResult ?? undefined,
|
|
21359
|
-
dnsServers: [
|
|
21360
|
-
dnsAllowRemote: (
|
|
21501
|
+
dnsServers: [dnsServersRaw.trim(), dnsDynamicRaw.trim()].filter(Boolean).join(",") || "",
|
|
21502
|
+
dnsAllowRemote: dnsAllowRemoteRaw.trim().toLowerCase() === "yes" || dnsAllowRemoteRaw.trim().toLowerCase() === "true",
|
|
21361
21503
|
cpuLoad: parsePercent(resKv["cpu-load"]) ?? 0,
|
|
21362
21504
|
memoryUsedPct: memUsedPct,
|
|
21363
21505
|
uptime: resKv.uptime ?? "",
|