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