@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.
@@ -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-c0fxxq85.js");
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/memory.ts
30974
+ // src/tools/capsman.ts
30975
30975
  import { z as z125 } from "zod";
30976
- var EntityInput = z125.object({
30977
- name: z125.string().describe("Unique name of the entity"),
30978
- entityType: z125.string().describe("Type/category of the entity (e.g. 'router', 'subnet', 'person')"),
30979
- observations: z125.array(z125.string()).optional().describe("Initial observations (facts) to attach")
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 = z125.object({
30982
- from: z125.string().describe("Source entity name"),
30983
- to: z125.string().describe("Target entity name"),
30984
- relationType: z125.string().describe("Relation type in active voice (e.g. 'manages', 'connects_to', 'depends_on')")
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: z125.array(EntityInput).min(1).describe("Entities to create")
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: z125.array(RelationInput).min(1).describe("Relations to create")
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: z125.array(z125.object({
31030
- entityName: z125.string().describe("Name of the existing entity"),
31031
- contents: z125.array(z125.string()).min(1).describe("Observations to add")
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: z125.array(z125.string()).min(1).describe("Names of entities to delete")
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: z125.array(z125.object({
31066
- entityName: z125.string().describe("Entity to remove observations from"),
31067
- observations: z125.array(z125.string()).min(1).describe("Exact observation strings to delete")
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: z125.array(RelationInput).min(1).describe("Relations to delete")
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: z125.string().describe("Search term \u2014 matched against entity names, types, and observation content"),
31111
- limit: z125.number().int().positive().optional().describe("Max entities to return (default 50)")
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: z125.array(z125.string()).min(1).describe("Exact entity names to retrieve")
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",