@usex/mikrotik-mcp 4.15.0 → 4.17.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/README.md +1 -0
- package/dist/cli.js +322 -33
- package/dist/index.js +1 -1
- package/dist/shared/{cli-9s02dq5d.js → cli-1ftjz1jx.js} +1 -1
- package/dist/shared/{cli-w6ecrhxa.js → cli-v75jtv6p.js} +1016 -24
- package/dist/shared/{library-212xbkwr.js → library-qma8metz.js} +976 -23
- package/dist/shared/{library-c0fxxq85.js → library-ydfn3v4j.js} +1 -1
- package/dist/ui/observability.html +58 -58
- package/package.json +1 -1
- package/schemas/README.md +1 -1
- package/schemas/tool-catalog.json +321 -2
- package/schemas/tools/apply_capsman_channel_plan.json +20 -0
- package/schemas/tools/apply_capsman_fixes.json +24 -0
- package/schemas/tools/apply_capsman_load_balance.json +13 -0
- package/schemas/tools/audit_capsman_coverage.json +7 -0
- package/schemas/tools/audit_capsman_ft.json +7 -0
- package/schemas/tools/audit_capsman_ha.json +7 -0
- package/schemas/tools/audit_capsman_load.json +7 -0
- package/schemas/tools/enable_capsman_ft.json +24 -0
- package/schemas/tools/report_weak_signal_clients.json +14 -0
- package/schemas/tools/run_capsman_audit.json +28 -0
- package/schemas/tools/setup_capsman_ha.json +17 -0
- package/schemas/tools/steer_client.json +37 -0
|
@@ -8804,7 +8804,7 @@ var cache = null;
|
|
|
8804
8804
|
async function gateway() {
|
|
8805
8805
|
if (cache)
|
|
8806
8806
|
return cache;
|
|
8807
|
-
const { moduleCatalog } = await import("./library-
|
|
8807
|
+
const { moduleCatalog } = await import("./library-ydfn3v4j.js");
|
|
8808
8808
|
const forIndex = [];
|
|
8809
8809
|
const byName = new Map;
|
|
8810
8810
|
for (const mod of moduleCatalog) {
|
|
@@ -30971,17 +30971,963 @@ ${monitor.trim() || "(empty)"}`;
|
|
|
30971
30971
|
})
|
|
30972
30972
|
];
|
|
30973
30973
|
|
|
30974
|
-
// src/tools/
|
|
30974
|
+
// src/tools/capsman.ts
|
|
30975
30975
|
import { z as z125 } from "zod";
|
|
30976
|
-
|
|
30977
|
-
|
|
30978
|
-
|
|
30979
|
-
|
|
30976
|
+
|
|
30977
|
+
// src/core/capsman.ts
|
|
30978
|
+
var SEVERITY_ORDER3 = {
|
|
30979
|
+
critical: 0,
|
|
30980
|
+
high: 1,
|
|
30981
|
+
medium: 2,
|
|
30982
|
+
low: 3
|
|
30983
|
+
};
|
|
30984
|
+
function emptyCapsmanState() {
|
|
30985
|
+
return {
|
|
30986
|
+
managerEnabled: false,
|
|
30987
|
+
managerCount: 0,
|
|
30988
|
+
capsHaveBackupManager: false,
|
|
30989
|
+
requirePeerCertificate: false,
|
|
30990
|
+
radios: [],
|
|
30991
|
+
clients: [],
|
|
30992
|
+
securityConfigs: [],
|
|
30993
|
+
accessList: [],
|
|
30994
|
+
path: "/interface wifi"
|
|
30995
|
+
};
|
|
30996
|
+
}
|
|
30997
|
+
var DEFAULT_WEAK_DBM = -70;
|
|
30998
|
+
var MIN_STEER_GAIN_DB = 8;
|
|
30999
|
+
var OVERLOAD_CLIENTS = 25;
|
|
31000
|
+
var CPU_CONSTRAINED_PCT = 80;
|
|
31001
|
+
function parseFloorTag(text) {
|
|
31002
|
+
if (!text)
|
|
31003
|
+
return {};
|
|
31004
|
+
const kvFloor = text.match(/floor\s*[=:]\s*([\w-]+)/i)?.[1];
|
|
31005
|
+
const kvZone = text.match(/zone\s*[=:]\s*([\w-]+)/i)?.[1];
|
|
31006
|
+
if (kvFloor || kvZone)
|
|
31007
|
+
return { floor: kvFloor, zone: kvZone };
|
|
31008
|
+
const f = text.match(/\bF(\d{1,2})\b/i) ?? text.match(/floor[-_]?(\d{1,2})/i);
|
|
31009
|
+
const z125 = text.match(/\b(?:F\d{1,2}|floor[-_]?\d{1,2})[-_]([A-Za-z]+)\b/i);
|
|
31010
|
+
const out = {};
|
|
31011
|
+
if (f)
|
|
31012
|
+
out.floor = f[1];
|
|
31013
|
+
if (z125)
|
|
31014
|
+
out.zone = z125[1];
|
|
31015
|
+
return out;
|
|
31016
|
+
}
|
|
31017
|
+
function buildAdjacency(clients) {
|
|
31018
|
+
const adj = new Map;
|
|
31019
|
+
const link = (a, b) => {
|
|
31020
|
+
if (a === b)
|
|
31021
|
+
return;
|
|
31022
|
+
(adj.get(a) ?? adj.set(a, new Set).get(a)).add(b);
|
|
31023
|
+
(adj.get(b) ?? adj.set(b, new Set).get(b)).add(a);
|
|
31024
|
+
};
|
|
31025
|
+
for (const c of clients) {
|
|
31026
|
+
if (!c.seenOn)
|
|
31027
|
+
continue;
|
|
31028
|
+
for (const [other, sig] of Object.entries(c.seenOn)) {
|
|
31029
|
+
if (other !== c.radioId && sig > -85)
|
|
31030
|
+
link(c.radioId, other);
|
|
31031
|
+
}
|
|
31032
|
+
}
|
|
31033
|
+
return adj;
|
|
31034
|
+
}
|
|
31035
|
+
function radioIndex(radios) {
|
|
31036
|
+
return new Map(radios.map((r) => [r.radioId, r]));
|
|
31037
|
+
}
|
|
31038
|
+
function bandOf(radio) {
|
|
31039
|
+
if (radio.band !== "unknown")
|
|
31040
|
+
return radio.band;
|
|
31041
|
+
const ch = radio.channel ?? 0;
|
|
31042
|
+
if (ch >= 5000 || ch >= 36 && ch <= 177)
|
|
31043
|
+
return "5ghz";
|
|
31044
|
+
if (ch >= 2400 && ch <= 2500 || ch >= 1 && ch <= 14)
|
|
31045
|
+
return "2ghz";
|
|
31046
|
+
return "unknown";
|
|
31047
|
+
}
|
|
31048
|
+
var CLEAN_24_CHANNELS = [1, 6, 11];
|
|
31049
|
+
function auditCoverage(state) {
|
|
31050
|
+
const findings = [];
|
|
31051
|
+
const adj = buildAdjacency(state.clients);
|
|
31052
|
+
const byId = radioIndex(state.radios);
|
|
31053
|
+
const seenPairs = new Set;
|
|
31054
|
+
for (const r of state.radios) {
|
|
31055
|
+
const neighbors = adj.get(r.radioId);
|
|
31056
|
+
if (!neighbors)
|
|
31057
|
+
continue;
|
|
31058
|
+
for (const nId of neighbors) {
|
|
31059
|
+
const n = byId.get(nId);
|
|
31060
|
+
if (!n)
|
|
31061
|
+
continue;
|
|
31062
|
+
if (bandOf(r) !== bandOf(n) || r.channel == null || r.channel !== n.channel)
|
|
31063
|
+
continue;
|
|
31064
|
+
const key = [r.radioId, nId].sort().join("|");
|
|
31065
|
+
if (seenPairs.has(key))
|
|
31066
|
+
continue;
|
|
31067
|
+
seenPairs.add(key);
|
|
31068
|
+
findings.push({
|
|
31069
|
+
finding_id: `cochannel:${key}`,
|
|
31070
|
+
category: "coverage",
|
|
31071
|
+
severity: "high",
|
|
31072
|
+
confidence: "proven",
|
|
31073
|
+
title: `Co-channel overlap on ${bandOf(r)} channel ${r.channel}`,
|
|
31074
|
+
target: `${r.cap}/${r.radioId} \u2194 ${n.cap}/${n.radioId}`,
|
|
31075
|
+
detail: `Adjacent radios ${r.cap} and ${n.cap} both use ${bandOf(r)} channel ${r.channel}; ` + "clients in the overlap contend for the same airtime, cutting throughput.",
|
|
31076
|
+
recommendation: bandOf(r) === "2ghz" ? `Move one radio to a non-overlapping 2.4 GHz channel (${CLEAN_24_CHANNELS.join("/")}).` : "Assign the two radios different 5 GHz channels (DFS-aware)."
|
|
31077
|
+
});
|
|
31078
|
+
}
|
|
31079
|
+
}
|
|
31080
|
+
return findings;
|
|
31081
|
+
}
|
|
31082
|
+
function proposeChannelPlan(state) {
|
|
31083
|
+
const adj = buildAdjacency(state.clients);
|
|
31084
|
+
const plan = new Map;
|
|
31085
|
+
const order = [...state.radios].sort((a, b) => b.clientCount - a.clientCount);
|
|
31086
|
+
for (const r of order) {
|
|
31087
|
+
const band = bandOf(r);
|
|
31088
|
+
const palette = band === "2ghz" ? CLEAN_24_CHANNELS : [36, 40, 44, 48, 149, 153, 157, 161];
|
|
31089
|
+
const used = new Set;
|
|
31090
|
+
for (const nId of adj.get(r.radioId) ?? []) {
|
|
31091
|
+
const nc = plan.get(nId);
|
|
31092
|
+
if (nc != null)
|
|
31093
|
+
used.add(nc);
|
|
31094
|
+
}
|
|
31095
|
+
plan.set(r.radioId, palette.find((c) => !used.has(c)) ?? palette[0]);
|
|
31096
|
+
}
|
|
31097
|
+
return plan;
|
|
31098
|
+
}
|
|
31099
|
+
function channelToFrequencyMhz(channel, band) {
|
|
31100
|
+
if (band === "2ghz")
|
|
31101
|
+
return channel >= 1 && channel <= 14 ? 2407 + channel * 5 : channel;
|
|
31102
|
+
if (band === "5ghz")
|
|
31103
|
+
return channel >= 36 && channel <= 177 ? 5000 + channel * 5 : channel;
|
|
31104
|
+
return null;
|
|
31105
|
+
}
|
|
31106
|
+
function buildChannelPlanCommands(state, onlyRadioIds) {
|
|
31107
|
+
if (state.path === "/caps-man")
|
|
31108
|
+
return [];
|
|
31109
|
+
const plan = proposeChannelPlan(state);
|
|
31110
|
+
const byId = radioIndex(state.radios);
|
|
31111
|
+
const cmds = [];
|
|
31112
|
+
for (const [radioId, ch] of plan) {
|
|
31113
|
+
if (onlyRadioIds && !onlyRadioIds.has(radioId))
|
|
31114
|
+
continue;
|
|
31115
|
+
const radio = byId.get(radioId);
|
|
31116
|
+
if (!radio || radio.channel === ch)
|
|
31117
|
+
continue;
|
|
31118
|
+
const freq = channelToFrequencyMhz(ch, bandOf(radio));
|
|
31119
|
+
if (freq == null)
|
|
31120
|
+
continue;
|
|
31121
|
+
cmds.push(`${state.path} set [find name="${radioId}"] channel.frequency=${freq}`);
|
|
31122
|
+
}
|
|
31123
|
+
return cmds;
|
|
31124
|
+
}
|
|
31125
|
+
function reportWeakClients(state, weakDbm = DEFAULT_WEAK_DBM) {
|
|
31126
|
+
const byId = radioIndex(state.radios);
|
|
31127
|
+
const out = [];
|
|
31128
|
+
for (const c of state.clients) {
|
|
31129
|
+
if (c.signal >= weakDbm)
|
|
31130
|
+
continue;
|
|
31131
|
+
const cur = byId.get(c.radioId);
|
|
31132
|
+
let bestId;
|
|
31133
|
+
let bestSig = c.signal;
|
|
31134
|
+
for (const [rid, sig] of Object.entries(c.seenOn ?? {})) {
|
|
31135
|
+
if (rid === c.radioId)
|
|
31136
|
+
continue;
|
|
31137
|
+
if (sig > bestSig) {
|
|
31138
|
+
bestSig = sig;
|
|
31139
|
+
bestId = rid;
|
|
31140
|
+
}
|
|
31141
|
+
}
|
|
31142
|
+
const gain = bestId ? bestSig - c.signal : 0;
|
|
31143
|
+
const rec = bestId && gain >= MIN_STEER_GAIN_DB ? byId.get(bestId) : undefined;
|
|
31144
|
+
out.push({
|
|
31145
|
+
mac: c.mac,
|
|
31146
|
+
currentRadio: c.radioId,
|
|
31147
|
+
currentCap: cur?.cap ?? "?",
|
|
31148
|
+
signal: c.signal,
|
|
31149
|
+
band: c.band,
|
|
31150
|
+
recommendRadioId: rec ? rec.radioId : undefined,
|
|
31151
|
+
recommendCap: rec ? rec.cap : undefined,
|
|
31152
|
+
gainDb: rec ? gain : undefined
|
|
31153
|
+
});
|
|
31154
|
+
}
|
|
31155
|
+
return out.sort((a, b) => a.signal - b.signal);
|
|
31156
|
+
}
|
|
31157
|
+
function auditWeakSignal(state, weakDbm = DEFAULT_WEAK_DBM) {
|
|
31158
|
+
return reportWeakClients(state, weakDbm).map((w) => ({
|
|
31159
|
+
finding_id: `weak:${w.mac}`,
|
|
31160
|
+
category: "weak_signal",
|
|
31161
|
+
severity: w.signal < weakDbm - 15 ? "high" : "medium",
|
|
31162
|
+
confidence: "needs_live_verification",
|
|
31163
|
+
title: `Weak client ${w.mac} at ${w.signal} dBm on ${w.currentCap}`,
|
|
31164
|
+
target: `${w.mac} @ ${w.currentCap}/${w.currentRadio}`,
|
|
31165
|
+
detail: `Client signal ${w.signal} dBm is below the ${weakDbm} dBm threshold${w.recommendCap ? `; it sees ${w.recommendCap} ~${w.gainDb} dB stronger.` : "; no neighbor radio hears it meaningfully better (coverage gap here)."}`,
|
|
31166
|
+
recommendation: w.recommendCap ? `Steer ${w.mac} toward ${w.recommendCap} (soft 802.11k/v, or hard signal-range).` : "Coverage hole \u2014 add/relocate an AP or raise tx-power; steering won't help."
|
|
31167
|
+
}));
|
|
31168
|
+
}
|
|
31169
|
+
function loadModel(state) {
|
|
31170
|
+
return state.radios.map((radio) => ({
|
|
31171
|
+
radio,
|
|
31172
|
+
overloaded: radio.clientCount > OVERLOAD_CLIENTS,
|
|
31173
|
+
cpuConstrained: (radio.cpuLoad ?? 0) > CPU_CONSTRAINED_PCT
|
|
31174
|
+
}));
|
|
31175
|
+
}
|
|
31176
|
+
function auditLoad(state) {
|
|
31177
|
+
const findings = [];
|
|
31178
|
+
const adj = buildAdjacency(state.clients);
|
|
31179
|
+
const byId = radioIndex(state.radios);
|
|
31180
|
+
const loads = loadModel(state);
|
|
31181
|
+
for (const l of loads) {
|
|
31182
|
+
if (!l.overloaded && !l.cpuConstrained)
|
|
31183
|
+
continue;
|
|
31184
|
+
let target;
|
|
31185
|
+
for (const nId of adj.get(l.radio.radioId) ?? []) {
|
|
31186
|
+
const n = byId.get(nId);
|
|
31187
|
+
if (!n)
|
|
31188
|
+
continue;
|
|
31189
|
+
if (n.clientCount < OVERLOAD_CLIENTS && (n.cpuLoad ?? 0) < CPU_CONSTRAINED_PCT) {
|
|
31190
|
+
if (!target || n.clientCount < target.clientCount)
|
|
31191
|
+
target = n;
|
|
31192
|
+
}
|
|
31193
|
+
}
|
|
31194
|
+
const why = l.cpuConstrained ? `CAP CPU at ${l.radio.cpuLoad}% (>${CPU_CONSTRAINED_PCT}%)` : `${l.radio.clientCount} clients (>${OVERLOAD_CLIENTS})`;
|
|
31195
|
+
findings.push({
|
|
31196
|
+
finding_id: `load:${l.radio.radioId}`,
|
|
31197
|
+
category: "load",
|
|
31198
|
+
severity: l.cpuConstrained ? "high" : "medium",
|
|
31199
|
+
confidence: "needs_live_verification",
|
|
31200
|
+
title: `Overloaded radio ${l.radio.cap}/${l.radio.radioId}`,
|
|
31201
|
+
target: `${l.radio.cap}/${l.radio.radioId}`,
|
|
31202
|
+
detail: `${why}.${target ? ` Neighbor ${target.cap} has spare capacity.` : ""}`,
|
|
31203
|
+
recommendation: target ? `Rebalance some clients toward ${target.cap}; steer dual-band clients to 5 GHz.` : "No adjacent radio has spare capacity \u2014 the whole zone is saturated; add an AP."
|
|
31204
|
+
});
|
|
31205
|
+
}
|
|
31206
|
+
return findings;
|
|
31207
|
+
}
|
|
31208
|
+
function auditFt(state) {
|
|
31209
|
+
const findings = [];
|
|
31210
|
+
if (state.securityConfigs.length === 0)
|
|
31211
|
+
return findings;
|
|
31212
|
+
for (const c of state.securityConfigs) {
|
|
31213
|
+
if (!c.ft) {
|
|
31214
|
+
findings.push({
|
|
31215
|
+
finding_id: `ft-off:${c.name}`,
|
|
31216
|
+
category: "ft",
|
|
31217
|
+
severity: "medium",
|
|
31218
|
+
confidence: "proven",
|
|
31219
|
+
title: `802.11r fast-transition off on "${c.ssid ?? c.name}"`,
|
|
31220
|
+
target: c.name,
|
|
31221
|
+
detail: "Without FT, roaming re-does the full auth handshake \u2014 a visible stall for VoIP/video as people move between floors.",
|
|
31222
|
+
recommendation: "Enable ft + a shared ft-mobility-domain across all CAPs, and 802.11k/v steering."
|
|
31223
|
+
});
|
|
31224
|
+
}
|
|
31225
|
+
if (c.ft && (!c.rrm || !c.wnm)) {
|
|
31226
|
+
findings.push({
|
|
31227
|
+
finding_id: `ft-nosteer:${c.name}`,
|
|
31228
|
+
category: "ft",
|
|
31229
|
+
severity: "low",
|
|
31230
|
+
confidence: "proven",
|
|
31231
|
+
title: `FT on but 802.11k/v steering incomplete on "${c.ssid ?? c.name}"`,
|
|
31232
|
+
target: c.name,
|
|
31233
|
+
detail: `rrm(k)=${c.rrm ? "on" : "off"}, wnm(v)=${c.wnm ? "on" : "off"} \u2014 clients get no neighbor hints to roam early.`,
|
|
31234
|
+
recommendation: "Enable both rrm (802.11k) and wnm (802.11v) so clients roam before the signal collapses."
|
|
31235
|
+
});
|
|
31236
|
+
}
|
|
31237
|
+
}
|
|
31238
|
+
const domains = new Set(state.securityConfigs.filter((c) => c.ft && c.ftMobilityDomain).map((c) => c.ftMobilityDomain));
|
|
31239
|
+
if (domains.size > 1) {
|
|
31240
|
+
findings.push({
|
|
31241
|
+
finding_id: "ft-domain-mismatch",
|
|
31242
|
+
category: "ft",
|
|
31243
|
+
severity: "high",
|
|
31244
|
+
confidence: "proven",
|
|
31245
|
+
title: "Inconsistent FT mobility domains across CAPs",
|
|
31246
|
+
target: [...domains].join(", "),
|
|
31247
|
+
detail: `Found ${domains.size} distinct ft-mobility-domain values \u2014 fast roaming only works WITHIN a domain, so clients still do a full handshake crossing the boundary.`,
|
|
31248
|
+
recommendation: "Set the SAME ft-mobility-domain on every CAP that shares an SSID."
|
|
31249
|
+
});
|
|
31250
|
+
}
|
|
31251
|
+
return findings;
|
|
31252
|
+
}
|
|
31253
|
+
function auditHa(state) {
|
|
31254
|
+
const findings = [];
|
|
31255
|
+
if (!state.managerEnabled)
|
|
31256
|
+
return findings;
|
|
31257
|
+
if (state.managerCount < 2 || !state.capsHaveBackupManager) {
|
|
31258
|
+
findings.push({
|
|
31259
|
+
finding_id: "ha-single-manager",
|
|
31260
|
+
category: "ha",
|
|
31261
|
+
severity: "high",
|
|
31262
|
+
confidence: "proven",
|
|
31263
|
+
title: "CAPsMAN has no backup manager (single point of failure)",
|
|
31264
|
+
target: "capsman manager",
|
|
31265
|
+
detail: "Only one manager is configured / the CAPs aren't pointed at a backup. If it reboots or the link drops, every AP in the building goes dark.",
|
|
31266
|
+
recommendation: "Stand up a second manager and point the CAPs at both (caps-man-addresses / discovery), cert-based."
|
|
31267
|
+
});
|
|
31268
|
+
}
|
|
31269
|
+
if ((state.managerCount >= 2 || state.capsHaveBackupManager) && !state.requirePeerCertificate) {
|
|
31270
|
+
findings.push({
|
|
31271
|
+
finding_id: "ha-no-cert",
|
|
31272
|
+
category: "ha",
|
|
31273
|
+
severity: "medium",
|
|
31274
|
+
confidence: "proven",
|
|
31275
|
+
title: "CAPsMAN HA without peer-certificate enforcement",
|
|
31276
|
+
target: "capsman manager",
|
|
31277
|
+
detail: "A backup manager exists but require-peer-certificate is off \u2014 a rogue manager could adopt your CAPs.",
|
|
31278
|
+
recommendation: "Enable require-peer-certificate and provision CA/manager certificates."
|
|
31279
|
+
});
|
|
31280
|
+
}
|
|
31281
|
+
return findings;
|
|
31282
|
+
}
|
|
31283
|
+
function runCapsmanAudit(state, opts = {}) {
|
|
31284
|
+
const want = new Set(opts.categories ?? ["coverage", "weak_signal", "load", "ft", "ha"]);
|
|
31285
|
+
const findings = [];
|
|
31286
|
+
if (want.has("coverage"))
|
|
31287
|
+
findings.push(...auditCoverage(state));
|
|
31288
|
+
if (want.has("weak_signal"))
|
|
31289
|
+
findings.push(...auditWeakSignal(state, opts.weakDbm ?? DEFAULT_WEAK_DBM));
|
|
31290
|
+
if (want.has("load"))
|
|
31291
|
+
findings.push(...auditLoad(state));
|
|
31292
|
+
if (want.has("ft"))
|
|
31293
|
+
findings.push(...auditFt(state));
|
|
31294
|
+
if (want.has("ha"))
|
|
31295
|
+
findings.push(...auditHa(state));
|
|
31296
|
+
findings.sort((a, b) => SEVERITY_ORDER3[a.severity] - SEVERITY_ORDER3[b.severity] || a.category.localeCompare(b.category) || a.finding_id.localeCompare(b.finding_id));
|
|
31297
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
31298
|
+
for (const f of findings)
|
|
31299
|
+
summary[f.severity]++;
|
|
31300
|
+
return { findings, summary, total: findings.length };
|
|
31301
|
+
}
|
|
31302
|
+
var STEER_TAG = "capsman-steer";
|
|
31303
|
+
var LB_TAG = "capsman-lb";
|
|
31304
|
+
function accessListMenu(path) {
|
|
31305
|
+
return path === "/caps-man" ? "/caps-man access-list" : `${path} access-list`;
|
|
31306
|
+
}
|
|
31307
|
+
function steerAlreadyPresent(state, mac2) {
|
|
31308
|
+
const tag = `${STEER_TAG}: ${mac2.toLowerCase()}`;
|
|
31309
|
+
return state.accessList.some((e) => (e.comment ?? "").toLowerCase().includes(tag) || (e.macAddress ?? "").toLowerCase() === mac2.toLowerCase() && (e.comment ?? "").toLowerCase().includes(STEER_TAG));
|
|
31310
|
+
}
|
|
31311
|
+
function buildSteerCommands(state, mac2, currentRadio, mode, rejectAbove = DEFAULT_WEAK_DBM) {
|
|
31312
|
+
if (mode === "soft")
|
|
31313
|
+
return [];
|
|
31314
|
+
const menu = accessListMenu(state.path);
|
|
31315
|
+
return [
|
|
31316
|
+
`${menu} add mac-address=${mac2} interface=${currentRadio} ` + `signal-range=-120..${rejectAbove} action=reject ` + `comment="${STEER_TAG}: ${mac2}" place-before=0`
|
|
31317
|
+
];
|
|
31318
|
+
}
|
|
31319
|
+
function loadBalancePlan(state) {
|
|
31320
|
+
const adj = buildAdjacency(state.clients);
|
|
31321
|
+
const byId = radioIndex(state.radios);
|
|
31322
|
+
const out = [];
|
|
31323
|
+
for (const r of state.radios) {
|
|
31324
|
+
const overloaded = r.clientCount > OVERLOAD_CLIENTS || (r.cpuLoad ?? 0) > CPU_CONSTRAINED_PCT;
|
|
31325
|
+
if (!overloaded)
|
|
31326
|
+
continue;
|
|
31327
|
+
let target;
|
|
31328
|
+
for (const nId of adj.get(r.radioId) ?? []) {
|
|
31329
|
+
const n = byId.get(nId);
|
|
31330
|
+
if (!n)
|
|
31331
|
+
continue;
|
|
31332
|
+
if (n.clientCount < OVERLOAD_CLIENTS && (n.cpuLoad ?? 0) < CPU_CONSTRAINED_PCT) {
|
|
31333
|
+
if (!target || n.clientCount < target.clientCount)
|
|
31334
|
+
target = n;
|
|
31335
|
+
}
|
|
31336
|
+
}
|
|
31337
|
+
if (target)
|
|
31338
|
+
out.push({
|
|
31339
|
+
radioId: r.radioId,
|
|
31340
|
+
cap: r.cap,
|
|
31341
|
+
targetRadioId: target.radioId,
|
|
31342
|
+
targetCap: target.cap
|
|
31343
|
+
});
|
|
31344
|
+
}
|
|
31345
|
+
return out;
|
|
31346
|
+
}
|
|
31347
|
+
function loadBalanceAlreadyPresent(state, radioId) {
|
|
31348
|
+
const tag = `${LB_TAG}: ${radioId}`;
|
|
31349
|
+
return state.accessList.some((e) => (e.comment ?? "").includes(tag));
|
|
31350
|
+
}
|
|
31351
|
+
function buildLoadBalanceCommands(state, plan) {
|
|
31352
|
+
const menu = accessListMenu(state.path);
|
|
31353
|
+
const cmds = [];
|
|
31354
|
+
for (const item of plan) {
|
|
31355
|
+
if (loadBalanceAlreadyPresent(state, item.radioId))
|
|
31356
|
+
continue;
|
|
31357
|
+
cmds.push(`${menu} add interface=${item.radioId} action=accept connect-priority=0 ` + `comment="${LB_TAG}: ${item.radioId} \u2192 ${item.targetRadioId}" place-before=0`);
|
|
31358
|
+
}
|
|
31359
|
+
return cmds;
|
|
31360
|
+
}
|
|
31361
|
+
function securityMenu(path) {
|
|
31362
|
+
return path === "/caps-man" ? "/caps-man security" : `${path} security`;
|
|
31363
|
+
}
|
|
31364
|
+
function resolveMobilityDomain(state, override) {
|
|
31365
|
+
if (override)
|
|
31366
|
+
return override;
|
|
31367
|
+
const existing = state.securityConfigs.find((c) => c.ftMobilityDomain)?.ftMobilityDomain;
|
|
31368
|
+
return existing ?? "0001";
|
|
31369
|
+
}
|
|
31370
|
+
function buildFtCommands(state, opts = {}) {
|
|
31371
|
+
const menu = securityMenu(state.path);
|
|
31372
|
+
const domain = resolveMobilityDomain(state, opts.mobilityDomain);
|
|
31373
|
+
const want = opts.configNames ? new Set(opts.configNames) : null;
|
|
31374
|
+
const cmds = [];
|
|
31375
|
+
for (const c of state.securityConfigs) {
|
|
31376
|
+
if (want && !want.has(c.name))
|
|
31377
|
+
continue;
|
|
31378
|
+
if (c.ft && c.ftMobilityDomain === domain)
|
|
31379
|
+
continue;
|
|
31380
|
+
cmds.push(`${menu} set [find name="${c.name}"] ft=yes ft-over-ds=yes ft-mobility-domain=${domain}`);
|
|
31381
|
+
}
|
|
31382
|
+
return cmds;
|
|
31383
|
+
}
|
|
31384
|
+
function managerMenu(path) {
|
|
31385
|
+
return path === "/caps-man" ? "/caps-man manager" : `${path} capsman`;
|
|
31386
|
+
}
|
|
31387
|
+
function buildHaCommands(state, opts = {}) {
|
|
31388
|
+
const cmds = [];
|
|
31389
|
+
if ((opts.requireCert ?? true) && !state.requirePeerCertificate) {
|
|
31390
|
+
cmds.push(`${managerMenu(state.path)} set require-peer-certificate=yes`);
|
|
31391
|
+
}
|
|
31392
|
+
return cmds;
|
|
31393
|
+
}
|
|
31394
|
+
function haGuidance(state, backupAddress) {
|
|
31395
|
+
const g = [
|
|
31396
|
+
"Stand up a SECOND manager (a spare RouterOS box or a CHR) with the same CA/manager certificate.",
|
|
31397
|
+
backupAddress ? `On EVERY CAP, point it at both managers: /interface wifi cap set caps-man-addresses=<primary>,${backupAddress}` : "On EVERY CAP, add the backup manager to its caps-man-addresses (or discovery-interfaces) so it fails over.",
|
|
31398
|
+
"Provision the backup with an identical configuration/provisioning set so a failover is seamless."
|
|
31399
|
+
];
|
|
31400
|
+
if (!state.requirePeerCertificate) {
|
|
31401
|
+
g.unshift("(This tool can enable require-peer-certificate on THIS manager \u2014 see the applied commands.)");
|
|
31402
|
+
}
|
|
31403
|
+
return g;
|
|
31404
|
+
}
|
|
31405
|
+
var FIX_ORDER = ["coverage", "load", "weak_signal", "ft", "ha"];
|
|
31406
|
+
function fixCommandsForFinding(state, findingId) {
|
|
31407
|
+
if (findingId.startsWith("cochannel:"))
|
|
31408
|
+
return buildChannelPlanCommands(state);
|
|
31409
|
+
if (findingId.startsWith("load:"))
|
|
31410
|
+
return buildLoadBalanceCommands(state, loadBalancePlan(state));
|
|
31411
|
+
if (findingId.startsWith("weak:")) {
|
|
31412
|
+
const mac2 = findingId.slice("weak:".length);
|
|
31413
|
+
const client = state.clients.find((c) => c.mac === mac2);
|
|
31414
|
+
return client ? buildSteerCommands(state, mac2, client.radioId, "hard") : [];
|
|
31415
|
+
}
|
|
31416
|
+
if (findingId.startsWith("ft-"))
|
|
31417
|
+
return buildFtCommands(state);
|
|
31418
|
+
if (findingId.startsWith("ha-"))
|
|
31419
|
+
return buildHaCommands(state);
|
|
31420
|
+
return [];
|
|
31421
|
+
}
|
|
31422
|
+
function categoryOfFinding(findingId) {
|
|
31423
|
+
if (findingId.startsWith("cochannel:"))
|
|
31424
|
+
return "coverage";
|
|
31425
|
+
if (findingId.startsWith("load:"))
|
|
31426
|
+
return "load";
|
|
31427
|
+
if (findingId.startsWith("weak:"))
|
|
31428
|
+
return "weak_signal";
|
|
31429
|
+
if (findingId.startsWith("ft-"))
|
|
31430
|
+
return "ft";
|
|
31431
|
+
return "ha";
|
|
31432
|
+
}
|
|
31433
|
+
function buildFixPlan(state, findingIds) {
|
|
31434
|
+
const ordered = [...findingIds].sort((a, b) => FIX_ORDER.indexOf(categoryOfFinding(a)) - FIX_ORDER.indexOf(categoryOfFinding(b)));
|
|
31435
|
+
const seen = new Set;
|
|
31436
|
+
const out = [];
|
|
31437
|
+
for (const id of ordered) {
|
|
31438
|
+
for (const cmd of fixCommandsForFinding(state, id)) {
|
|
31439
|
+
if (!seen.has(cmd)) {
|
|
31440
|
+
seen.add(cmd);
|
|
31441
|
+
out.push(cmd);
|
|
31442
|
+
}
|
|
31443
|
+
}
|
|
31444
|
+
}
|
|
31445
|
+
return out;
|
|
31446
|
+
}
|
|
31447
|
+
var SEV_TAG3 = {
|
|
31448
|
+
critical: "CRIT",
|
|
31449
|
+
high: "HIGH",
|
|
31450
|
+
medium: "MED ",
|
|
31451
|
+
low: "LOW "
|
|
31452
|
+
};
|
|
31453
|
+
function renderCapsmanReport(report, device) {
|
|
31454
|
+
const head = `CAPsMAN AUDIT \u2014 ${device}
|
|
31455
|
+
` + `${report.total} finding(s): ${report.summary.critical} critical, ${report.summary.high} high, ` + `${report.summary.medium} medium, ${report.summary.low} low`;
|
|
31456
|
+
if (report.total === 0)
|
|
31457
|
+
return `${head}
|
|
31458
|
+
|
|
31459
|
+
No findings \u2014 Wi-Fi fabric looks healthy. \u2713`;
|
|
31460
|
+
const body = report.findings.map((f, i) => {
|
|
31461
|
+
return `${i + 1}. [${SEV_TAG3[f.severity]}] ${f.title}
|
|
31462
|
+
` + ` id=${f.finding_id} category=${f.category} confidence=${f.confidence}
|
|
31463
|
+
` + ` target : ${f.target}
|
|
31464
|
+
` + ` detail : ${f.detail}
|
|
31465
|
+
` + ` suggest: ${f.recommendation}`;
|
|
31466
|
+
}).join(`
|
|
31467
|
+
|
|
31468
|
+
`);
|
|
31469
|
+
return `${head}
|
|
31470
|
+
|
|
31471
|
+
${body}`;
|
|
31472
|
+
}
|
|
31473
|
+
|
|
31474
|
+
// src/core/capsman-normalize.ts
|
|
31475
|
+
function yes(v) {
|
|
31476
|
+
return (v ?? "").trim().toLowerCase() === "yes" || (v ?? "").trim().toLowerCase() === "true";
|
|
31477
|
+
}
|
|
31478
|
+
function num2(v) {
|
|
31479
|
+
if (v == null)
|
|
31480
|
+
return;
|
|
31481
|
+
const n = Number.parseFloat(v.replace(/[^\d.-]/g, ""));
|
|
31482
|
+
return Number.isFinite(n) ? n : undefined;
|
|
31483
|
+
}
|
|
31484
|
+
function bandFromRow(row) {
|
|
31485
|
+
const b = (row.band ?? row["configuration.mode"] ?? "").toLowerCase();
|
|
31486
|
+
if (b.includes("2ghz") || b.includes("2.4"))
|
|
31487
|
+
return "2ghz";
|
|
31488
|
+
if (b.includes("5ghz") || b.includes("5."))
|
|
31489
|
+
return "5ghz";
|
|
31490
|
+
const ch = num2(row.channel ?? row.frequency ?? row["channel.frequency"]);
|
|
31491
|
+
if (ch != null) {
|
|
31492
|
+
if (ch >= 5000 || ch >= 36 && ch <= 177)
|
|
31493
|
+
return "5ghz";
|
|
31494
|
+
if (ch >= 2400 && ch <= 2500 || ch >= 1 && ch <= 14)
|
|
31495
|
+
return "2ghz";
|
|
31496
|
+
}
|
|
31497
|
+
return "unknown";
|
|
31498
|
+
}
|
|
31499
|
+
function channelOf(row) {
|
|
31500
|
+
return num2(row.channel ?? row.frequency ?? row["channel.frequency"]);
|
|
31501
|
+
}
|
|
31502
|
+
function normalizeCapsmanState(raw) {
|
|
31503
|
+
if (!raw)
|
|
31504
|
+
return emptyCapsmanState();
|
|
31505
|
+
const clientsByRadio = new Map;
|
|
31506
|
+
for (const r of raw.registrations) {
|
|
31507
|
+
const rid = r.interface ?? r.radio ?? r.ap ?? "";
|
|
31508
|
+
if (rid)
|
|
31509
|
+
clientsByRadio.set(rid, (clientsByRadio.get(rid) ?? 0) + 1);
|
|
31510
|
+
}
|
|
31511
|
+
const radios = raw.radios.map((row) => {
|
|
31512
|
+
const cap = row["remote-cap-identity"] ?? row.identity ?? row["cap-name"] ?? row.name ?? "?";
|
|
31513
|
+
const radioId = row.interface ?? row.name ?? row["radio-mac"] ?? cap;
|
|
31514
|
+
const tag = parseFloorTag(`${cap} ${row.comment ?? ""}`);
|
|
31515
|
+
const res = raw.resources[cap] ?? {};
|
|
31516
|
+
return {
|
|
31517
|
+
cap,
|
|
31518
|
+
radioId,
|
|
31519
|
+
band: bandFromRow(row),
|
|
31520
|
+
channel: channelOf(row),
|
|
31521
|
+
width: row.width ?? row["channel.width"],
|
|
31522
|
+
txPower: num2(row["tx-power"] ?? row["tx-power-dbm"]),
|
|
31523
|
+
clientCount: clientsByRadio.get(radioId) ?? num2(row["registered-clients"]) ?? 0,
|
|
31524
|
+
floor: tag.floor,
|
|
31525
|
+
zone: tag.zone,
|
|
31526
|
+
cpuLoad: res.cpuLoad,
|
|
31527
|
+
memUsedPct: res.memUsedPct
|
|
31528
|
+
};
|
|
31529
|
+
});
|
|
31530
|
+
const radioBand = new Map(radios.map((r) => [r.radioId, r.band]));
|
|
31531
|
+
const clients = raw.registrations.map((r) => {
|
|
31532
|
+
const radioId = r.interface ?? r.radio ?? r.ap ?? "";
|
|
31533
|
+
return {
|
|
31534
|
+
mac: r["mac-address"] ?? r.mac ?? "?",
|
|
31535
|
+
radioId,
|
|
31536
|
+
signal: num2(r.signal ?? r["signal-strength"] ?? r["rx-signal"]) ?? -100,
|
|
31537
|
+
band: radioBand.get(radioId) ?? "unknown",
|
|
31538
|
+
txRate: r["tx-rate"],
|
|
31539
|
+
rxRate: r["rx-rate"],
|
|
31540
|
+
uptime: r.uptime,
|
|
31541
|
+
seenOn: undefined
|
|
31542
|
+
};
|
|
31543
|
+
});
|
|
31544
|
+
const securityConfigs = raw.securityConfigs.map((c) => ({
|
|
31545
|
+
name: c.name ?? "?",
|
|
31546
|
+
ssid: c.ssid ?? c["configuration.ssid"],
|
|
31547
|
+
ft: yes(c.ft),
|
|
31548
|
+
ftOverDs: yes(c["ft-over-ds"]),
|
|
31549
|
+
ftMobilityDomain: c["ft-mobility-domain"] || undefined,
|
|
31550
|
+
rrm: yes(c.rrm ?? c["steering.rrm"]),
|
|
31551
|
+
wnm: yes(c.wnm ?? c["steering.wnm"])
|
|
31552
|
+
}));
|
|
31553
|
+
const accessList = (raw.accessList ?? []).map((e) => ({
|
|
31554
|
+
macAddress: e["mac-address"] ?? e.mac,
|
|
31555
|
+
interface: e.interface,
|
|
31556
|
+
comment: e.comment
|
|
31557
|
+
}));
|
|
31558
|
+
const managerEnabled = yes(raw.manager.enabled);
|
|
31559
|
+
const addrs = (raw.manager["caps-man-addresses"] ?? raw.manager["caps-man-names"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
31560
|
+
const managerCount = Math.max(managerEnabled ? 1 : 0, addrs.length);
|
|
31561
|
+
return {
|
|
31562
|
+
managerEnabled,
|
|
31563
|
+
managerCount,
|
|
31564
|
+
capsHaveBackupManager: addrs.length > 1,
|
|
31565
|
+
requirePeerCertificate: yes(raw.manager["require-peer-certificate"]),
|
|
31566
|
+
radios,
|
|
31567
|
+
clients,
|
|
31568
|
+
securityConfigs,
|
|
31569
|
+
accessList,
|
|
31570
|
+
path: raw.path
|
|
31571
|
+
};
|
|
31572
|
+
}
|
|
31573
|
+
|
|
31574
|
+
// src/utils/wifi-query.ts
|
|
31575
|
+
var WIFI_PATHS = ["/interface wifi", "/interface wifiwave2", "/interface wireless"];
|
|
31576
|
+
async function detectWifiPath(ctx) {
|
|
31577
|
+
for (const p of WIFI_PATHS) {
|
|
31578
|
+
const out = await safe(`${p} print count-only`, ctx);
|
|
31579
|
+
if (out !== "" && !commandUnsupported(out)) {
|
|
31580
|
+
if (p === "/interface wireless") {
|
|
31581
|
+
const caps2 = await safe("/caps-man manager print", ctx);
|
|
31582
|
+
if (caps2 !== "" && !commandUnsupported(caps2))
|
|
31583
|
+
return "/caps-man";
|
|
31584
|
+
}
|
|
31585
|
+
return p;
|
|
31586
|
+
}
|
|
31587
|
+
}
|
|
31588
|
+
const caps = await safe("/caps-man manager print", ctx);
|
|
31589
|
+
if (caps !== "" && !commandUnsupported(caps))
|
|
31590
|
+
return "/caps-man";
|
|
31591
|
+
return null;
|
|
31592
|
+
}
|
|
31593
|
+
async function fetchCapsmanState(ctx) {
|
|
31594
|
+
const path = await detectWifiPath(ctx);
|
|
31595
|
+
if (!path)
|
|
31596
|
+
return normalizeCapsmanState(null);
|
|
31597
|
+
const isCapsman = path === "/caps-man";
|
|
31598
|
+
const [manager, remoteCaps, radios, registrations, securityConfigs, accessList] = await Promise.all([
|
|
31599
|
+
fetchKv(isCapsman ? "/caps-man manager print" : `${path} capsman print`, ctx),
|
|
31600
|
+
fetchRows(isCapsman ? "/caps-man remote-cap print detail" : `${path} capsman remote-cap print detail`, ctx),
|
|
31601
|
+
fetchRows(isCapsman ? "/caps-man radio print detail" : `${path} radio print detail`, ctx),
|
|
31602
|
+
fetchRows(isCapsman ? "/caps-man registration-table print detail" : `${path} registration-table print detail`, ctx),
|
|
31603
|
+
fetchRows(isCapsman ? "/caps-man security print detail" : `${path} security print detail`, ctx),
|
|
31604
|
+
fetchRows(isCapsman ? "/caps-man access-list print detail" : `${path} access-list print detail`, ctx)
|
|
31605
|
+
]);
|
|
31606
|
+
const raw = {
|
|
31607
|
+
path,
|
|
31608
|
+
manager,
|
|
31609
|
+
remoteCaps,
|
|
31610
|
+
radios,
|
|
31611
|
+
registrations,
|
|
31612
|
+
securityConfigs,
|
|
31613
|
+
accessList,
|
|
31614
|
+
resources: {}
|
|
31615
|
+
};
|
|
31616
|
+
return normalizeCapsmanState(raw);
|
|
31617
|
+
}
|
|
31618
|
+
|
|
31619
|
+
// src/tools/capsman.ts
|
|
31620
|
+
async function applyCapsman(ctx, device, commands, label) {
|
|
31621
|
+
if (commands.length === 0)
|
|
31622
|
+
return "Nothing to apply \u2014 already in the desired state (idempotent no-op).";
|
|
31623
|
+
const snapshotId = await captureSnapshot(ctx, label);
|
|
31624
|
+
const outcome = await applyWritesSafely(ctx, device, commands, { allowDirectFallback: false });
|
|
31625
|
+
const lines = [
|
|
31626
|
+
`CAPsMAN APPLY \u2014 snapshot=${snapshotId} safe-mode=${outcome.safeMode}`,
|
|
31627
|
+
`Applied ${outcome.applied}/${outcome.total} command(s).`,
|
|
31628
|
+
...outcome.error ? [`FAILED: ${outcome.error}`] : [],
|
|
31629
|
+
"",
|
|
31630
|
+
...commands.map((c) => ` ${c}`),
|
|
31631
|
+
"",
|
|
31632
|
+
`Roll back with: diff_config_snapshots from=${snapshotId} to=live`
|
|
31633
|
+
];
|
|
31634
|
+
return lines.join(`
|
|
31635
|
+
`);
|
|
31636
|
+
}
|
|
31637
|
+
var weakDbm = z125.number().int().max(-1).optional().describe(`Weak-signal threshold in dBm (negative; default ${DEFAULT_WEAK_DBM}).`);
|
|
31638
|
+
async function auditOne(ctx, category, opts = {}) {
|
|
31639
|
+
const device = resolveDeviceName(ctx.device);
|
|
31640
|
+
const state = await fetchCapsmanState(ctx);
|
|
31641
|
+
const report = runCapsmanAudit(state, { categories: [category], weakDbm: opts.weakDbm });
|
|
31642
|
+
return renderCapsmanReport(report, device);
|
|
31643
|
+
}
|
|
31644
|
+
var capsmanTools = [
|
|
31645
|
+
defineTool({
|
|
31646
|
+
name: "run_capsman_audit",
|
|
31647
|
+
title: "Run CAPsMAN Audit",
|
|
31648
|
+
annotations: READ,
|
|
31649
|
+
description: "Read-only. Audits the device's CAPsMAN Wi-Fi fabric across coverage/co-channel, weak-signal " + "clients, resource-aware load, 802.11r (FT) roaming, and HA (backup-manager) redundancy \u2014 one " + "severity-ranked report with per-finding id, severity and confidence (proven vs " + "needs_live_verification; steering/balancing are advisory since RouterOS has no force-move). " + "Supports both the v7 `/interface wifi` CAPsMAN and legacy `/caps-man`. Narrow with `categories`. " + "For a single dimension use audit_capsman_coverage / report_weak_signal_clients / " + "audit_capsman_load / audit_capsman_ft / audit_capsman_ha.",
|
|
31650
|
+
inputSchema: {
|
|
31651
|
+
categories: z125.array(z125.enum(["coverage", "weak_signal", "load", "ft", "ha"])).optional().describe("Narrow to specific categories. Omit to audit all five."),
|
|
31652
|
+
weak_dbm: weakDbm
|
|
31653
|
+
},
|
|
31654
|
+
async handler(a, ctx) {
|
|
31655
|
+
const device = resolveDeviceName(ctx.device);
|
|
31656
|
+
ctx.info(`[${device}] run_capsman_audit`);
|
|
31657
|
+
const state = await fetchCapsmanState(ctx);
|
|
31658
|
+
const report = runCapsmanAudit(state, {
|
|
31659
|
+
categories: a.categories,
|
|
31660
|
+
weakDbm: a.weak_dbm
|
|
31661
|
+
});
|
|
31662
|
+
return renderCapsmanReport(report, device);
|
|
31663
|
+
}
|
|
31664
|
+
}),
|
|
31665
|
+
defineTool({
|
|
31666
|
+
name: "audit_capsman_coverage",
|
|
31667
|
+
title: "Audit CAPsMAN Coverage & Channels",
|
|
31668
|
+
annotations: READ,
|
|
31669
|
+
description: "Read-only. Inventories every managed CAP radio (band, manual channel/width, tx-power, client " + "count), detects CO-CHANNEL overlap between physically-adjacent radios (adjacency inferred from " + "clients seen on more than one radio), and proposes a non-overlapping manual channel plan " + "(2.4 GHz \u2192 1/6/11; 5 GHz DFS-aware). Preview only \u2014 apply is a later, confirm-gated tool.",
|
|
31670
|
+
async handler(_a, ctx) {
|
|
31671
|
+
const device = resolveDeviceName(ctx.device);
|
|
31672
|
+
ctx.info(`[${device}] audit_capsman_coverage`);
|
|
31673
|
+
const state = await fetchCapsmanState(ctx);
|
|
31674
|
+
const report = runCapsmanAudit(state, { categories: ["coverage"] });
|
|
31675
|
+
const plan = proposeChannelPlan(state);
|
|
31676
|
+
const planLines = [...plan.entries()].map(([rid, ch]) => {
|
|
31677
|
+
const r = state.radios.find((radio) => radio.radioId === rid);
|
|
31678
|
+
return ` ${r?.cap ?? "?"}/${rid} (${r?.band ?? "?"}) \u2192 channel ${ch}`;
|
|
31679
|
+
}).join(`
|
|
31680
|
+
`);
|
|
31681
|
+
return renderCapsmanReport(report, device) + (planLines ? `
|
|
31682
|
+
|
|
31683
|
+
PROPOSED MANUAL CHANNEL PLAN (preview):
|
|
31684
|
+
${planLines}` : "");
|
|
31685
|
+
}
|
|
31686
|
+
}),
|
|
31687
|
+
defineTool({
|
|
31688
|
+
name: "report_weak_signal_clients",
|
|
31689
|
+
title: "Report Weak-Signal Clients",
|
|
31690
|
+
annotations: READ,
|
|
31691
|
+
description: "Read-only. Merges every CAP's registration-table and lists clients below the weak-signal " + "threshold (default -70 dBm), each with its current AP, signal, band, and \u2014 when a neighbor " + "radio hears it meaningfully stronger \u2014 the recommended AP to steer toward and the dB gain. " + "Steering itself is a later, confirm-gated tool (soft 802.11k/v or hard signal-range).",
|
|
31692
|
+
inputSchema: { weak_dbm: weakDbm },
|
|
31693
|
+
async handler(a, ctx) {
|
|
31694
|
+
const device = resolveDeviceName(ctx.device);
|
|
31695
|
+
ctx.info(`[${device}] report_weak_signal_clients`);
|
|
31696
|
+
const state = await fetchCapsmanState(ctx);
|
|
31697
|
+
const weak = reportWeakClients(state, a.weak_dbm ?? DEFAULT_WEAK_DBM);
|
|
31698
|
+
if (weak.length === 0) {
|
|
31699
|
+
return `WEAK-SIGNAL CLIENTS \u2014 ${device}
|
|
31700
|
+
|
|
31701
|
+
None below ${a.weak_dbm ?? DEFAULT_WEAK_DBM} dBm. \u2713`;
|
|
31702
|
+
}
|
|
31703
|
+
const lines = weak.map((w) => ` ${w.mac} ${w.signal} dBm ${w.band} on ${w.currentCap}${w.recommendCap ? ` \u2192 steer to ${w.recommendCap} (+${w.gainDb} dB)` : " (coverage gap)"}`).join(`
|
|
31704
|
+
`);
|
|
31705
|
+
return `WEAK-SIGNAL CLIENTS \u2014 ${device} (${weak.length} below ${a.weak_dbm ?? DEFAULT_WEAK_DBM} dBm):
|
|
31706
|
+
|
|
31707
|
+
${lines}`;
|
|
31708
|
+
}
|
|
31709
|
+
}),
|
|
31710
|
+
defineTool({
|
|
31711
|
+
name: "audit_capsman_load",
|
|
31712
|
+
title: "Audit CAPsMAN Load & Resources",
|
|
31713
|
+
annotations: READ,
|
|
31714
|
+
description: "Read-only. Reports per-radio client load and the owning CAP's CPU/mem, flags overloaded or " + "resource-constrained radios that have an adjacent radio with spare capacity, and recommends " + "a resource-aware rebalance (offload toward the idle neighbor; steer dual-band clients to 5 GHz). " + "Advisory \u2014 apply is a later, confirm-gated tool.",
|
|
31715
|
+
async handler(_a, ctx) {
|
|
31716
|
+
return auditOne(ctx, "load");
|
|
31717
|
+
}
|
|
31718
|
+
}),
|
|
31719
|
+
defineTool({
|
|
31720
|
+
name: "audit_capsman_ft",
|
|
31721
|
+
title: "Audit CAPsMAN Fast-Roaming (802.11r)",
|
|
31722
|
+
annotations: READ,
|
|
31723
|
+
description: "Read-only. Checks 802.11r fast-transition per SSID: is `ft` enabled, is 802.11k/v steering on, " + "and is the `ft-mobility-domain` CONSISTENT across all CAPs (fast roaming only works within one " + "domain). Reports roam-readiness. Enabling FT is a later, confirm-gated tool.",
|
|
31724
|
+
async handler(_a, ctx) {
|
|
31725
|
+
return auditOne(ctx, "ft");
|
|
31726
|
+
}
|
|
31727
|
+
}),
|
|
31728
|
+
defineTool({
|
|
31729
|
+
name: "audit_capsman_ha",
|
|
31730
|
+
title: "Audit CAPsMAN High-Availability",
|
|
31731
|
+
annotations: READ,
|
|
31732
|
+
description: "Read-only. Checks CAPsMAN redundancy: is there a backup manager, are the CAPs pointed at both " + "managers, and is require-peer-certificate on? Flags single points of failure (one manager = the " + "whole building's Wi-Fi drops if it reboots). Setting up HA is a later, confirm-gated tool.",
|
|
31733
|
+
async handler(_a, ctx) {
|
|
31734
|
+
return auditOne(ctx, "ha");
|
|
31735
|
+
}
|
|
31736
|
+
}),
|
|
31737
|
+
defineTool({
|
|
31738
|
+
name: "steer_client",
|
|
31739
|
+
title: "Steer a Weak-Signal Client",
|
|
31740
|
+
annotations: DANGEROUS,
|
|
31741
|
+
description: "Steers ONE weak client toward a better AP. `mode=hard` installs an access-list signal-range " + "REJECT on the client's current radio so it re-associates on a neighbor it hears stronger (can " + "briefly disconnect it). `mode=soft` is 802.11k/v-only and installs no rule (advisory \u2014 enable " + "steering via enable_capsman_ft). ADVISORY: RouterOS has no force-move, so the client ultimately " + "decides. DRY RUN unless confirm=true. Snapshots first and applies inside Safe Mode (auto-revert " + "on lockout). Idempotent \u2014 a re-run for the same MAC adds nothing.",
|
|
31742
|
+
inputSchema: {
|
|
31743
|
+
mac: z125.string().min(1).describe("Client MAC address to steer."),
|
|
31744
|
+
mode: z125.enum(["soft", "hard"]).default("hard").describe("hard = signal-range reject; soft = k/v only (no write)."),
|
|
31745
|
+
reject_above_dbm: z125.number().int().max(-1).optional().describe(`dBm ceiling of the reject band for hard mode (default ${DEFAULT_WEAK_DBM}).`),
|
|
31746
|
+
confirm: z125.literal(true).optional().describe("Must be true to write; omit for a dry-run preview.")
|
|
31747
|
+
},
|
|
31748
|
+
async handler(a, ctx) {
|
|
31749
|
+
const device = resolveDeviceName(ctx.device);
|
|
31750
|
+
const state = await fetchCapsmanState(ctx);
|
|
31751
|
+
const client = state.clients.find((c) => c.mac.toLowerCase() === a.mac.toLowerCase());
|
|
31752
|
+
if (!client)
|
|
31753
|
+
return `Client ${a.mac} is not currently associated to any managed radio.`;
|
|
31754
|
+
if (steerAlreadyPresent(state, a.mac)) {
|
|
31755
|
+
return `A steer rule for ${a.mac} already exists (idempotent no-op). Remove it manually to change.`;
|
|
31756
|
+
}
|
|
31757
|
+
const commands = buildSteerCommands(state, a.mac, client.radioId, a.mode, a.reject_above_dbm);
|
|
31758
|
+
if (a.mode === "soft") {
|
|
31759
|
+
return `Soft steer is advisory-only (802.11k/v hints) \u2014 no access-list rule written. Ensure 802.11k/v is enabled with enable_capsman_ft, then the client roams on its own. Current signal ${client.signal} dBm on ${client.radioId}.`;
|
|
31760
|
+
}
|
|
31761
|
+
if (!a.confirm) {
|
|
31762
|
+
return `DRY RUN \u2014 steer ${a.mac} (${a.mode}); set confirm=true to apply:
|
|
31763
|
+
|
|
31764
|
+
${commands.map((c) => ` ${c}`).join(`
|
|
31765
|
+
`)}`;
|
|
31766
|
+
}
|
|
31767
|
+
return applyCapsman(ctx, device, commands, `pre-steer_client-${a.mac}`);
|
|
31768
|
+
}
|
|
31769
|
+
}),
|
|
31770
|
+
defineTool({
|
|
31771
|
+
name: "apply_capsman_load_balance",
|
|
31772
|
+
title: "Apply CAPsMAN Load Balance",
|
|
31773
|
+
annotations: DANGEROUS,
|
|
31774
|
+
description: "Applies a resource-aware rebalance: for each overloaded / CPU-constrained radio that has an " + "adjacent radio with spare capacity, installs a connect-priority nudge so NEW clients prefer the " + "idle neighbor (gentle \u2014 does not disconnect existing clients). ADVISORY. DRY RUN unless " + "confirm=true. Snapshots first, applies inside Safe Mode. Idempotent. Preview the plan with " + "audit_capsman_load first.",
|
|
31775
|
+
inputSchema: {
|
|
31776
|
+
confirm: z125.literal(true).optional().describe("Must be true to write; omit for a dry-run preview.")
|
|
31777
|
+
},
|
|
31778
|
+
async handler(a, ctx) {
|
|
31779
|
+
const device = resolveDeviceName(ctx.device);
|
|
31780
|
+
const state = await fetchCapsmanState(ctx);
|
|
31781
|
+
const plan = loadBalancePlan(state);
|
|
31782
|
+
if (plan.length === 0)
|
|
31783
|
+
return "No rebalance needed \u2014 no overloaded radio has an idle adjacent neighbor.";
|
|
31784
|
+
const commands = buildLoadBalanceCommands(state, plan);
|
|
31785
|
+
if (commands.length === 0)
|
|
31786
|
+
return "Load-balance rules already present (idempotent no-op).";
|
|
31787
|
+
const preview = plan.map((p) => ` ${p.cap}/${p.radioId} \u2192 offload toward ${p.targetCap}/${p.targetRadioId}`).join(`
|
|
31788
|
+
`);
|
|
31789
|
+
if (!a.confirm) {
|
|
31790
|
+
return `DRY RUN \u2014 load-balance plan (set confirm=true to apply):
|
|
31791
|
+
${preview}
|
|
31792
|
+
|
|
31793
|
+
Commands:
|
|
31794
|
+
${commands.map((c) => ` ${c}`).join(`
|
|
31795
|
+
`)}`;
|
|
31796
|
+
}
|
|
31797
|
+
return applyCapsman(ctx, device, commands, "pre-apply_capsman_load_balance");
|
|
31798
|
+
}
|
|
31799
|
+
}),
|
|
31800
|
+
defineTool({
|
|
31801
|
+
name: "apply_capsman_channel_plan",
|
|
31802
|
+
title: "Apply CAPsMAN Channel Plan",
|
|
31803
|
+
annotations: DANGEROUS,
|
|
31804
|
+
description: "Applies the proposed non-overlapping manual channel plan (from audit_capsman_coverage) to the " + "managed radios by setting each radio's frequency \u2014 resolving co-channel conflicts. Optionally " + "scope to specific radios with `radio_ids`. DRY RUN unless confirm=true. Snapshots first, applies " + "inside Safe Mode; idempotent (radios already on the target channel are skipped). v7 " + "`/interface wifi` only \u2014 on legacy `/caps-man` the channel lives in named channel objects, so " + "edit those manually (the audit still shows the plan).",
|
|
31805
|
+
inputSchema: {
|
|
31806
|
+
radio_ids: z125.array(z125.string()).optional().describe("Only re-channel these radio ids. Omit to apply the whole plan."),
|
|
31807
|
+
confirm: z125.literal(true).optional().describe("Must be true to write; omit for a dry-run preview.")
|
|
31808
|
+
},
|
|
31809
|
+
async handler(a, ctx) {
|
|
31810
|
+
const device = resolveDeviceName(ctx.device);
|
|
31811
|
+
const state = await fetchCapsmanState(ctx);
|
|
31812
|
+
if (state.path === "/caps-man") {
|
|
31813
|
+
return "Channel-plan apply targets v7 /interface wifi. This device uses legacy /caps-man \u2014 edit the /caps-man channel objects manually; audit_capsman_coverage shows the proposed plan.";
|
|
31814
|
+
}
|
|
31815
|
+
const only = a.radio_ids ? new Set(a.radio_ids) : undefined;
|
|
31816
|
+
const commands = buildChannelPlanCommands(state, only);
|
|
31817
|
+
if (commands.length === 0)
|
|
31818
|
+
return "No channel changes needed \u2014 every radio is already on its proposed channel (idempotent no-op).";
|
|
31819
|
+
if (!a.confirm) {
|
|
31820
|
+
return `DRY RUN \u2014 channel plan (set confirm=true to apply):
|
|
31821
|
+
|
|
31822
|
+
${commands.map((c) => ` ${c}`).join(`
|
|
31823
|
+
`)}`;
|
|
31824
|
+
}
|
|
31825
|
+
return applyCapsman(ctx, device, commands, "pre-apply_capsman_channel_plan");
|
|
31826
|
+
}
|
|
31827
|
+
}),
|
|
31828
|
+
defineTool({
|
|
31829
|
+
name: "enable_capsman_ft",
|
|
31830
|
+
title: "Enable CAPsMAN Fast-Roaming (802.11r)",
|
|
31831
|
+
annotations: DANGEROUS,
|
|
31832
|
+
description: "Enables 802.11r fast-transition on the CAPsMAN security configs, converging every one on a " + "SINGLE shared `ft-mobility-domain` (so roaming between floors is seamless and consistent) \u2014 " + "fixing the ft-off and ft-domain-mismatch findings. Scope with `config_names`; set the domain " + "with `mobility_domain` (default: the existing domain, else 0001). Note: enabling FT changes the " + "SSID's roaming behaviour and briefly re-keys clients. DRY RUN unless confirm=true. Snapshots " + "first, applies inside Safe Mode; idempotent. 802.11k/v steering is configured separately.",
|
|
31833
|
+
inputSchema: {
|
|
31834
|
+
config_names: z125.array(z125.string()).optional().describe("Only enable FT on these security configs. Omit for all."),
|
|
31835
|
+
mobility_domain: z125.string().optional().describe("Shared ft-mobility-domain to converge on. Omit to adopt the existing one (or 0001)."),
|
|
31836
|
+
confirm: z125.literal(true).optional().describe("Must be true to write; omit for a dry-run preview.")
|
|
31837
|
+
},
|
|
31838
|
+
async handler(a, ctx) {
|
|
31839
|
+
const device = resolveDeviceName(ctx.device);
|
|
31840
|
+
const state = await fetchCapsmanState(ctx);
|
|
31841
|
+
if (state.securityConfigs.length === 0) {
|
|
31842
|
+
return "No CAPsMAN security configs found \u2014 nothing to enable FT on.";
|
|
31843
|
+
}
|
|
31844
|
+
const commands = buildFtCommands(state, {
|
|
31845
|
+
configNames: a.config_names,
|
|
31846
|
+
mobilityDomain: a.mobility_domain
|
|
31847
|
+
});
|
|
31848
|
+
if (commands.length === 0) {
|
|
31849
|
+
return `FT already enabled and consistent (mobility-domain=${resolveMobilityDomain(state, a.mobility_domain)}). Idempotent no-op.`;
|
|
31850
|
+
}
|
|
31851
|
+
if (!a.confirm) {
|
|
31852
|
+
return `DRY RUN \u2014 enable FT (set confirm=true to apply):
|
|
31853
|
+
|
|
31854
|
+
${commands.map((c) => ` ${c}`).join(`
|
|
31855
|
+
`)}`;
|
|
31856
|
+
}
|
|
31857
|
+
return applyCapsman(ctx, device, commands, "pre-enable_capsman_ft");
|
|
31858
|
+
}
|
|
31859
|
+
}),
|
|
31860
|
+
defineTool({
|
|
31861
|
+
name: "setup_capsman_ha",
|
|
31862
|
+
title: "Set Up CAPsMAN High-Availability",
|
|
31863
|
+
annotations: DANGEROUS,
|
|
31864
|
+
description: "Hardens CAPsMAN redundancy. Applies what THIS manager can safely do \u2014 enable " + "require-peer-certificate (so a rogue manager can't adopt your CAPs) \u2014 and returns the exact " + "manual, multi-device steps to finish HA (stand up a second manager with the same certificate, " + "point every CAP at both managers). Standing up the backup + editing each CAP is inherently " + "multi-device, so it is NOT auto-applied. HIGHEST blast radius \u2014 DRY RUN unless confirm=true; " + "snapshots first, applies inside Safe Mode. Requires a `reason`.",
|
|
31865
|
+
inputSchema: {
|
|
31866
|
+
backup_manager_address: z125.string().optional().describe("Address of the (planned) backup manager, woven into the guidance."),
|
|
31867
|
+
confirm: z125.literal(true).optional().describe("Must be true to write; omit for a dry-run preview.")
|
|
31868
|
+
},
|
|
31869
|
+
async handler(a, ctx) {
|
|
31870
|
+
const device = resolveDeviceName(ctx.device);
|
|
31871
|
+
const state = await fetchCapsmanState(ctx);
|
|
31872
|
+
if (!state.managerEnabled)
|
|
31873
|
+
return "This device is not a CAPsMAN manager \u2014 HA setup does not apply.";
|
|
31874
|
+
const commands = buildHaCommands(state);
|
|
31875
|
+
const guidance = haGuidance(state, a.backup_manager_address);
|
|
31876
|
+
const guide = `
|
|
31877
|
+
|
|
31878
|
+
MANUAL STEPS TO COMPLETE HA (multi-device \u2014 not auto-applied):
|
|
31879
|
+
${guidance.map((g) => ` \u2022 ${g}`).join(`
|
|
31880
|
+
`)}`;
|
|
31881
|
+
if (commands.length === 0) {
|
|
31882
|
+
return `require-peer-certificate is already enabled on this manager.${guide}`;
|
|
31883
|
+
}
|
|
31884
|
+
if (!a.confirm) {
|
|
31885
|
+
return `DRY RUN \u2014 HA hardening on this manager (set confirm=true to apply):
|
|
31886
|
+
|
|
31887
|
+
${commands.map((c) => ` ${c}`).join(`
|
|
31888
|
+
`)}${guide}`;
|
|
31889
|
+
}
|
|
31890
|
+
return await applyCapsman(ctx, device, commands, "pre-setup_capsman_ha") + guide;
|
|
31891
|
+
}
|
|
31892
|
+
}),
|
|
31893
|
+
defineTool({
|
|
31894
|
+
name: "apply_capsman_fixes",
|
|
31895
|
+
title: "Apply CAPsMAN Fixes",
|
|
31896
|
+
annotations: DANGEROUS,
|
|
31897
|
+
description: "Applies specific finding_ids from a prior run_capsman_audit, dispatching each to its remediation " + "in a SAFE order (coverage/channel-plan \u2192 load-balance \u2192 steer \u2192 FT \u2192 HA), all wrapped in ONE " + "snapshot + ONE Safe-Mode session. Returns the applied commands + snapshot id. DRY RUN unless " + "confirm=true. NEVER a blanket 'fix everything' \u2014 pass explicit finding_ids from an audit. " + "Steering/balancing remain advisory; HA require-cert is applied but the multi-device HA steps are not.",
|
|
31898
|
+
inputSchema: {
|
|
31899
|
+
finding_ids: z125.array(z125.string()).min(1).describe("Explicit finding_id(s) from a prior run_capsman_audit. No blanket apply."),
|
|
31900
|
+
confirm: z125.literal(true).optional().describe("Must be true to write; omit for a dry-run preview.")
|
|
31901
|
+
},
|
|
31902
|
+
async handler(a, ctx) {
|
|
31903
|
+
const device = resolveDeviceName(ctx.device);
|
|
31904
|
+
const state = await fetchCapsmanState(ctx);
|
|
31905
|
+
const commands = buildFixPlan(state, a.finding_ids);
|
|
31906
|
+
if (commands.length === 0) {
|
|
31907
|
+
return "No applicable automated fix for those finding_ids (already fixed, or manual-only like the HA multi-device steps).";
|
|
31908
|
+
}
|
|
31909
|
+
if (!a.confirm) {
|
|
31910
|
+
return `DRY RUN \u2014 ${commands.length} command(s) in safe order (set confirm=true to apply):
|
|
31911
|
+
|
|
31912
|
+
${commands.map((c) => ` ${c}`).join(`
|
|
31913
|
+
`)}`;
|
|
31914
|
+
}
|
|
31915
|
+
return applyCapsman(ctx, device, commands, "pre-apply_capsman_fixes");
|
|
31916
|
+
}
|
|
31917
|
+
})
|
|
31918
|
+
];
|
|
31919
|
+
|
|
31920
|
+
// src/tools/memory.ts
|
|
31921
|
+
import { z as z126 } from "zod";
|
|
31922
|
+
var EntityInput = z126.object({
|
|
31923
|
+
name: z126.string().describe("Unique name of the entity"),
|
|
31924
|
+
entityType: z126.string().describe("Type/category of the entity (e.g. 'router', 'subnet', 'person')"),
|
|
31925
|
+
observations: z126.array(z126.string()).optional().describe("Initial observations (facts) to attach")
|
|
30980
31926
|
});
|
|
30981
|
-
var RelationInput =
|
|
30982
|
-
from:
|
|
30983
|
-
to:
|
|
30984
|
-
relationType:
|
|
31927
|
+
var RelationInput = z126.object({
|
|
31928
|
+
from: z126.string().describe("Source entity name"),
|
|
31929
|
+
to: z126.string().describe("Target entity name"),
|
|
31930
|
+
relationType: z126.string().describe("Relation type in active voice (e.g. 'manages', 'connects_to', 'depends_on')")
|
|
30985
31931
|
});
|
|
30986
31932
|
var memoryTools = [
|
|
30987
31933
|
defineTool({
|
|
@@ -30990,7 +31936,7 @@ var memoryTools = [
|
|
|
30990
31936
|
annotations: WRITE,
|
|
30991
31937
|
description: "Create one or more new entities in the persistent knowledge graph. Each entity has a " + "unique name, a type (e.g. 'router', 'subnet', 'vlan', 'person', 'config_pattern'), " + "and optional initial observations. Entities that already exist are silently skipped. " + "Use this to record things the AI learns about the network, devices, users, or patterns.",
|
|
30992
31938
|
inputSchema: {
|
|
30993
|
-
entities:
|
|
31939
|
+
entities: z126.array(EntityInput).min(1).describe("Entities to create")
|
|
30994
31940
|
},
|
|
30995
31941
|
async handler(args) {
|
|
30996
31942
|
const store3 = await getMemoryStore();
|
|
@@ -31008,7 +31954,7 @@ ${created.map((e) => ` - ${e.name} (${e.entityType})`).join(`
|
|
|
31008
31954
|
annotations: WRITE,
|
|
31009
31955
|
description: "Create directed relations between existing entities in the knowledge graph. Both " + "endpoint entities must already exist. Use active voice for relation types (e.g. " + "'manages', 'connects_to', 'provides_dhcp_for', 'part_of'). Duplicate relations " + "are silently skipped.",
|
|
31010
31956
|
inputSchema: {
|
|
31011
|
-
relations:
|
|
31957
|
+
relations: z126.array(RelationInput).min(1).describe("Relations to create")
|
|
31012
31958
|
},
|
|
31013
31959
|
async handler(args) {
|
|
31014
31960
|
const store3 = await getMemoryStore();
|
|
@@ -31026,9 +31972,9 @@ ${created.map((r) => ` - ${r.from} --[${r.relationType}]--> ${r.to}`).join(`
|
|
|
31026
31972
|
annotations: WRITE,
|
|
31027
31973
|
description: "Add new observations (discrete facts) to existing entities in the knowledge graph. " + "Each observation is a string (e.g. 'runs RouterOS 7.16', 'has 4 ether ports', " + "'managed by John'). Duplicate observations on the same entity are silently skipped. " + "The entity must already exist.",
|
|
31028
31974
|
inputSchema: {
|
|
31029
|
-
observations:
|
|
31030
|
-
entityName:
|
|
31031
|
-
contents:
|
|
31975
|
+
observations: z126.array(z126.object({
|
|
31976
|
+
entityName: z126.string().describe("Name of the existing entity"),
|
|
31977
|
+
contents: z126.array(z126.string()).min(1).describe("Observations to add")
|
|
31032
31978
|
})).min(1)
|
|
31033
31979
|
},
|
|
31034
31980
|
async handler(args) {
|
|
@@ -31048,7 +31994,7 @@ ${lines.join(`
|
|
|
31048
31994
|
annotations: DESTRUCTIVE,
|
|
31049
31995
|
description: "Remove entities from the knowledge graph. This also deletes all their observations " + "and any relations where they appear as an endpoint (cascade delete).",
|
|
31050
31996
|
inputSchema: {
|
|
31051
|
-
entityNames:
|
|
31997
|
+
entityNames: z126.array(z126.string()).min(1).describe("Names of entities to delete")
|
|
31052
31998
|
},
|
|
31053
31999
|
async handler(args) {
|
|
31054
32000
|
const store3 = await getMemoryStore();
|
|
@@ -31062,9 +32008,9 @@ ${lines.join(`
|
|
|
31062
32008
|
annotations: DESTRUCTIVE,
|
|
31063
32009
|
description: "Remove specific observations from entities in the knowledge graph. The entity " + "itself is kept; only the named observation strings are removed.",
|
|
31064
32010
|
inputSchema: {
|
|
31065
|
-
deletions:
|
|
31066
|
-
entityName:
|
|
31067
|
-
observations:
|
|
32011
|
+
deletions: z126.array(z126.object({
|
|
32012
|
+
entityName: z126.string().describe("Entity to remove observations from"),
|
|
32013
|
+
observations: z126.array(z126.string()).min(1).describe("Exact observation strings to delete")
|
|
31068
32014
|
})).min(1)
|
|
31069
32015
|
},
|
|
31070
32016
|
async handler(args) {
|
|
@@ -31079,7 +32025,7 @@ ${lines.join(`
|
|
|
31079
32025
|
annotations: DESTRUCTIVE,
|
|
31080
32026
|
description: "Remove specific relations from the knowledge graph. Each relation is identified " + "by its (from, to, relationType) triple.",
|
|
31081
32027
|
inputSchema: {
|
|
31082
|
-
relations:
|
|
32028
|
+
relations: z126.array(RelationInput).min(1).describe("Relations to delete")
|
|
31083
32029
|
},
|
|
31084
32030
|
async handler(args) {
|
|
31085
32031
|
const store3 = await getMemoryStore();
|
|
@@ -31107,8 +32053,8 @@ ${lines.join(`
|
|
|
31107
32053
|
annotations: READ,
|
|
31108
32054
|
description: "Search for entities in the knowledge graph by name, type, or observation content. " + "Returns matching entities with their observations, plus any relations where at " + "least one endpoint is in the result set.",
|
|
31109
32055
|
inputSchema: {
|
|
31110
|
-
query:
|
|
31111
|
-
limit:
|
|
32056
|
+
query: z126.string().describe("Search term \u2014 matched against entity names, types, and observation content"),
|
|
32057
|
+
limit: z126.number().int().positive().optional().describe("Max entities to return (default 50)")
|
|
31112
32058
|
},
|
|
31113
32059
|
async handler(args) {
|
|
31114
32060
|
const store3 = await getMemoryStore();
|
|
@@ -31124,7 +32070,7 @@ ${lines.join(`
|
|
|
31124
32070
|
annotations: READ,
|
|
31125
32071
|
description: "Retrieve specific entities by exact name from the knowledge graph, with all their " + "observations and any relations where at least one endpoint is in the requested set.",
|
|
31126
32072
|
inputSchema: {
|
|
31127
|
-
names:
|
|
32073
|
+
names: z126.array(z126.string()).min(1).describe("Exact entity names to retrieve")
|
|
31128
32074
|
},
|
|
31129
32075
|
async handler(args) {
|
|
31130
32076
|
const store3 = await getMemoryStore();
|
|
@@ -31201,6 +32147,13 @@ var moduleCatalog = [
|
|
|
31201
32147
|
description: "Survey RF channel usage and tune a wireless radio to the least-congested frequency, with " + "preview-before-apply (legacy `/interface wireless`).",
|
|
31202
32148
|
tools: wifiOptimizerTools
|
|
31203
32149
|
},
|
|
32150
|
+
{
|
|
32151
|
+
label: "CAPsMAN Orchestrator",
|
|
32152
|
+
slug: "capsman",
|
|
32153
|
+
group: "Interfaces",
|
|
32154
|
+
description: "Enterprise CAPsMAN Wi-Fi control-plane audit: coverage/co-channel, weak-signal clients + " + "neighbor steering, resource-aware load, 802.11r fast-roaming (FT) and HA redundancy \u2014 one " + "severity-ranked report (read-only; steering/apply land in later phases). Supports v7 " + "`/interface wifi` CAPsMAN and legacy `/caps-man`.",
|
|
32155
|
+
tools: capsmanTools
|
|
32156
|
+
},
|
|
31204
32157
|
{
|
|
31205
32158
|
label: "PoE",
|
|
31206
32159
|
slug: "poe",
|